Skip to content

Commit

Permalink
feat(config): implement config lexicon
Browse files Browse the repository at this point in the history
Signed-off-by: Maxence Lange <[email protected]>
ArtificialOwl committed Nov 15, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 4141c8a commit a9051c2
Showing 11 changed files with 547 additions and 1 deletion.
3 changes: 3 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
@@ -11,6 +11,9 @@
'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',
'NCU\\Config\\IUserPreferences' => $baseDir . '/lib/unstable/Config/IUserPreferences.php',
'NCU\\Config\\Lexicon\\ConfigLexiconEntry' => $baseDir . '/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php',
'NCU\\Config\\Lexicon\\ConfigLexiconStrictness' => $baseDir . '/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php',
'NCU\\Config\\Lexicon\\IConfigLexicon' => $baseDir . '/lib/unstable/Config/Lexicon/IConfigLexicon.php',
'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php',
'OCP\\Accounts\\IAccount' => $baseDir . '/lib/public/Accounts/IAccount.php',
'OCP\\Accounts\\IAccountManager' => $baseDir . '/lib/public/Accounts/IAccountManager.php',
3 changes: 3 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
@@ -52,6 +52,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',
'NCU\\Config\\IUserPreferences' => __DIR__ . '/../../..' . '/lib/unstable/Config/IUserPreferences.php',
'NCU\\Config\\Lexicon\\ConfigLexiconEntry' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/ConfigLexiconEntry.php',
'NCU\\Config\\Lexicon\\ConfigLexiconStrictness' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php',
'NCU\\Config\\Lexicon\\IConfigLexicon' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/IConfigLexicon.php',
'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php',
'OCP\\Accounts\\IAccount' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccount.php',
'OCP\\Accounts\\IAccountManager' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountManager.php',
121 changes: 121 additions & 0 deletions lib/private/AppConfig.php
Original file line number Diff line number Diff line change
@@ -11,6 +11,10 @@

use InvalidArgumentException;
use JsonException;
use NCU\Config\Lexicon\ConfigLexiconEntry;
use NCU\Config\Lexicon\ConfigLexiconStrictness;
use NCU\Config\Lexicon\IConfigLexicon;
use OC\AppFramework\Bootstrap\Coordinator;
use OCP\DB\Exception as DBException;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Exceptions\AppConfigIncorrectTypeException;
@@ -55,6 +59,8 @@ class AppConfig implements IAppConfig {
private array $valueTypes = []; // type for all config values
private bool $fastLoaded = false;
private bool $lazyLoaded = false;
/** @var array<array-key, array{entries: array<array-key, ConfigLexiconEntry>, strictness: ConfigLexiconStrictness}> ['app_id' => ['strictness' => ConfigLexiconStrictness, 'entries' => ['config_key' => ConfigLexiconEntry[]]] */
private array $configLexiconDetails = [];

/**
* $migrationCompleted is only needed to manage the previous structure
@@ -430,6 +436,9 @@ private function getTypedValue(
int $type,
): string {
$this->assertParams($app, $key, valueType: $type);
if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, $default)) {
return $default; // returns default if strictness of lexicon is set to WARNING (block and report)
}
$this->loadConfig($app, $lazy);

/**
@@ -721,6 +730,9 @@ private function setTypedValue(
int $type,
): bool {
$this->assertParams($app, $key);
if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type)) {
return false; // returns false as database is not updated
}
$this->loadConfig(null, $lazy);

$sensitive = $this->isTyped(self::VALUE_SENSITIVE, $type);
@@ -1559,4 +1571,113 @@ private function getSensitiveKeys(string $app): array {
public function clearCachedConfig(): void {
$this->clearCache();
}

/**
* verify and compare current use of config values with defined lexicon
*
* @throws AppConfigUnknownKeyException
* @throws AppConfigTypeConflictException
*/
private function compareRegisteredConfigValues(
string $app,
string $key,
bool &$lazy,
int &$type,
string &$default = '',
): bool {
if (in_array($key,
[
'enabled',
'installed_version',
'types',
])) {
return false;
}
$configDetails = $this->getConfigDetailsFromLexicon($app);
if (!array_key_exists($key, $configDetails['entries'])) {
return $this->applyLexiconStrictness(
$configDetails['strictness'],
'The app config key ' . $app . '/' . $key . ' is not defined in the config lexicon'
);
}

/** @var ConfigLexiconEntry $configValue */
$configValue = $configDetails['entries'][$key];
$type &= ~self::VALUE_SENSITIVE;

if ($type === self::VALUE_MIXED) {
$type = $configValue->getValueType()->value; // we overwrite if value was requested as mixed
} elseif ($configValue->getValueType()->value !== $type) {
throw new AppConfigTypeConflictException('The app config key ' . $app . '/' . $key . ' is typed incorrectly in relation to the config lexicon');
}

$lazy = $configValue->isLazy();
$default = $configValue->getDefault() ?? $default; // default from Lexicon got priority
if ($configValue->isFlagged(self::FLAG_SENSITIVE)) {
$type |= self::VALUE_SENSITIVE;
}
if ($configValue->isDeprecated()) {
$this->logger->notice('App config key ' . $app . '/' . $key . ' is set as deprecated.');
}

return true;
}

/**
* manage ConfigLexicon behavior based on strictness set in IConfigLexicon
*
* @see IConfigLexicon::getStrictness()
* @param string $app
* @param string $key
* @param ConfigLexiconStrictness $strictness
*
* @return bool TRUE if conflict can be fully ignored
* @throws AppConfigUnknownKeyException
*/
private function applyLexiconStrictness(
?ConfigLexiconStrictness $strictness,
string $line = '',
): bool {
if ($strictness === null) {
return true;
}

switch ($strictness) {
case ConfigLexiconStrictness::IGNORE:
return true;
case ConfigLexiconStrictness::NOTICE:
$this->logger->notice($line);
return true;
case ConfigLexiconStrictness::WARNING:
$this->logger->warning($line);
return false;
}

throw new AppConfigUnknownKeyException($line);
}

/**
* extract details from registered $appId's config lexicon
*
* @param string $appId
*
* @return array{entries: array<array-key, ConfigLexiconEntry>, strictness: ConfigLexiconStrictness}
*/
private function getConfigDetailsFromLexicon(string $appId): array {
if (!array_key_exists($appId, $this->configLexiconDetails)) {
$entries = [];
$bootstrapCoordinator = \OCP\Server::get(Coordinator::class);
$configLexicon = $bootstrapCoordinator->getRegistrationContext()?->getConfigLexicon($appId);
foreach ($configLexicon?->getAppConfigs() ?? [] as $configEntry) {
$entries[$configEntry->getKey()] = $configEntry;
}

$this->configLexiconDetails[$appId] = [
'entries' => $entries,
'strictness' => $configLexicon?->getStrictness() ?? ConfigLexiconStrictness::IGNORE
];
}

return $this->configLexiconDetails[$appId];
}
}
34 changes: 34 additions & 0 deletions lib/private/AppFramework/Bootstrap/RegistrationContext.php
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
namespace OC\AppFramework\Bootstrap;

use Closure;
use NCU\Config\Lexicon\IConfigLexicon;
use OC\Support\CrashReport\Registry;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
@@ -141,6 +142,9 @@ class RegistrationContext {
/** @var ServiceRegistration<IDeclarativeSettingsForm>[] */
private array $declarativeSettings = [];

/** @var array<array-key, string> */
private array $configLexiconClasses = [];

/** @var ServiceRegistration<ITeamResourceProvider>[] */
private array $teamResourceProviders = [];

@@ -422,6 +426,13 @@ public function registerMailProvider(string $class): void {
$class
);
}

public function registerConfigLexicon(string $configLexiconClass): void {
$this->context->registerConfigLexicon(
$this->appId,
$configLexiconClass
);
}
};
}

@@ -621,6 +632,13 @@ public function registerMailProvider(string $appId, string $class): void {
$this->mailProviders[] = new ServiceRegistration($appId, $class);
}

/**
* @psalm-param class-string<IConfigLexicon> $configLexiconClass
*/
public function registerConfigLexicon(string $appId, string $configLexiconClass): void {
$this->configLexiconClasses[$appId] = $configLexiconClass;
}

/**
* @param App[] $apps
*/
@@ -972,4 +990,20 @@ public function getTaskProcessingTaskTypes(): array {
public function getMailProviders(): array {
return $this->mailProviders;
}

/**
* returns IConfigLexicon registered by the app.
* null if none registered.
*
* @param string $appId
*
* @return IConfigLexicon|null
*/
public function getConfigLexicon(string $appId): ?IConfigLexicon {
if (!array_key_exists($appId, $this->configLexiconClasses)) {
return null;
}

return \OCP\Server::get($this->configLexiconClasses[$appId]);
}
}
106 changes: 105 additions & 1 deletion lib/private/Config/UserPreferences.php
Original file line number Diff line number Diff line change
@@ -15,7 +15,10 @@
use NCU\Config\Exceptions\TypeConflictException;
use NCU\Config\Exceptions\UnknownKeyException;
use NCU\Config\IUserPreferences;
use NCU\Config\Lexicon\ConfigLexiconEntry;
use NCU\Config\Lexicon\ConfigLexiconStrictness;
use NCU\Config\ValueType;
use OC\AppFramework\Bootstrap\Coordinator;
use OCP\DB\Exception as DBException;
use OCP\DB\IResult;
use OCP\DB\QueryBuilder\IQueryBuilder;
@@ -63,6 +66,8 @@ class UserPreferences implements IUserPreferences {
private array $fastLoaded = [];
/** @var array<string, boolean> ['user_id' => bool] */
private array $lazyLoaded = [];
/** @var array<array-key, array{entries: array<array-key, ConfigLexiconEntry>, strictness: ConfigLexiconStrictness}> ['app_id' => ['strictness' => ConfigLexiconStrictness, 'entries' => ['config_key' => ConfigLexiconEntry[]]] */
private array $configLexiconDetails = [];

public function __construct(
protected IDBConnection $connection,
@@ -706,6 +711,9 @@ private function getTypedValue(
ValueType $type,
): string {
$this->assertParams($userId, $app, $key);
if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, default: $default)) {
return $default; // returns default if strictness of lexicon is set to WARNING (block and report)
}
$this->loadPreferences($userId, $lazy);

/**
@@ -1038,14 +1046,17 @@ private function setTypedValue(
ValueType $type,
): bool {
$this->assertParams($userId, $app, $key);
if (!$this->compareRegisteredConfigValues($app, $key, $lazy, $type, $flags)) {
return false; // returns false as database is not updated
}
$this->loadPreferences($userId, $lazy);

$inserted = $refreshCache = false;
$origValue = $value;
$sensitive = $this->isFlagged(self::FLAG_SENSITIVE, $flags);
if ($sensitive || ($this->hasKey($userId, $app, $key, $lazy) && $this->isSensitive($userId, $app, $key, $lazy))) {
$value = self::ENCRYPTION_PREFIX . $this->crypto->encrypt($value);
$flags |= UserPreferences::FLAG_SENSITIVE;
$flags |= self::FLAG_SENSITIVE;
}

// if requested, we fill the 'indexed' field with current value
@@ -1803,4 +1814,97 @@ private function decryptSensitiveValue(string $userId, string $app, string $key,
]);
}
}


/**
* verify and compare current use of config values with defined lexicon
*
* @throws UnknownKeyException
* @throws TypeConflictException
*/
private function compareRegisteredConfigValues(
string $app,
string $key,
bool &$lazy,
ValueType &$type,
int &$flags = 0,
string &$default = '',
): bool {
$configDetails = $this->getConfigDetailsFromLexicon($app);
if (!array_key_exists($key, $configDetails['entries'])) {
return $this->applyLexiconStrictness($configDetails['strictness'], 'The user preference key ' . $app . '/' . $key . ' is not defined in the config lexicon');
}

/** @var ConfigLexiconEntry $configValue */
$configValue = $configDetails['entries'][$key];
if ($type === ValueType::MIXED) {
$type = $configValue->getValueType()->value; // we overwrite if value was requested as mixed
} elseif ($configValue->getValueType()->value !== $type) {
throw new TypeConflictException('The user preference key ' . $app . '/' . $key . ' is typed incorrectly in relation to the config lexicon');
}

$lazy = $configValue->isLazy();
$default = $configValue->getDefault() ?? $default; // default from Lexicon got priority
$flags = $configValue->getFlags();

if ($configValue->isDeprecated()) {
$this->logger->notice('User preference key ' . $app . '/' . $key . ' is set as deprecated.');
}

return true;
}

/**
* manage ConfigLexicon behavior based on strictness set in IConfigLexicon
*
* @see IConfigLexicon::getStrictness()
* @param ConfigLexiconStrictness|null $strictness
* @param string $line
*
* @return bool TRUE if conflict can be fully ignored
* @throws UnknownKeyException
*/
private function applyLexiconStrictness(?ConfigLexiconStrictness $strictness, string $line = ''): bool {
if ($strictness === null) {
return true;
}

switch ($strictness) {
case ConfigLexiconStrictness::IGNORE:
return true;
case ConfigLexiconStrictness::NOTICE:
$this->logger->notice($line);
return true;
case ConfigLexiconStrictness::WARNING:
$this->logger->warning($line);
return false;
}

throw new UnknownKeyException($line);
}

/**
* extract details from registered $appId's config lexicon
*
* @param string $appId
*
* @return array{entries: array<array-key, ConfigLexiconEntry>, strictness: ConfigLexiconStrictness}
*/
private function getConfigDetailsFromLexicon(string $appId): array {
if (!array_key_exists($appId, $this->configLexiconDetails)) {
$entries = [];
$bootstrapCoordinator = \OCP\Server::get(Coordinator::class);
$configLexicon = $bootstrapCoordinator->getRegistrationContext()?->getConfigLexicon($appId);
foreach ($configLexicon?->getUserPreferences() ?? [] as $configEntry) {
$entries[$configEntry->getKey()] = $configEntry;
}

$this->configLexiconDetails[$appId] = [
'entries' => $entries,
'strictness' => $configLexicon?->getStrictness() ?? ConfigLexiconStrictness::IGNORE
];
}

return $this->configLexiconDetails[$appId];
}
}
11 changes: 11 additions & 0 deletions lib/public/AppFramework/Bootstrap/IRegistrationContext.php
Original file line number Diff line number Diff line change
@@ -423,4 +423,15 @@ public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeCla
*/
public function registerMailProvider(string $class): void;


/**
* Register an implementation of \OCP\ConfigLexicon\IConfigLexicon that
* will handle the implementation of config lexicon
*
* @param string $configLexiconClass
*
* @psalm-param class-string<\NCU\Config\Lexicon\IConfigLexicon> $configLexiconClass
* @since 31.0.0
*/
public function registerConfigLexicon(string $configLexiconClass): void;
}
3 changes: 3 additions & 0 deletions lib/public/IAppConfig.php
Original file line number Diff line number Diff line change
@@ -45,6 +45,9 @@ interface IAppConfig {
/** @since 29.0.0 */
public const VALUE_ARRAY = 64;

/** @since 31.0.0 */
public const FLAG_SENSITIVE = 1; // value is sensitive

/**
* Get list of all apps that have at least one config value stored in database
*
186 changes: 186 additions & 0 deletions lib/unstable/Config/Lexicon/ConfigLexiconEntry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace NCU\Config\Lexicon;

use NCU\Config\ValueType;

/**
* Model that represent config values within an app config lexicon.
*
* @see IConfigLexicon
* @experimental 31.0.0
* @since 31.0.0
*/
class ConfigLexiconEntry {
private string $definition = '';
private ?string $default = null;

/**
* @param string $key config key
* @param ValueType $type type of config value
* @param string $definition optional description of config key available when using occ command
* @param bool $lazy set config value as lazy
* @param int $flags set flags
* @param bool $deprecated set config key as deprecated
*
* @since 31.0.0
*/
public function __construct(
private readonly string $key,
private readonly ValueType $type,
null|string|int|float|bool|array $default = null,
string $definition = '',
private readonly bool $lazy = false,
private readonly int $flags = 0,
private readonly bool $deprecated = false,
) {
if ($default !== null) {
$this->default = match ($type) {
ValueType::MIXED => (string)$default,
ValueType::STRING => $this->convertFromString((string)$default),
ValueType::INT => $this->convertFromInt((int)$default),
ValueType::FLOAT => $this->convertFromFloat((float)$default),
ValueType::BOOL => $this->convertFromBool((bool)$default),
ValueType::ARRAY => $this->convertFromArray((array)$default)
};
}

/** @psalm-suppress UndefinedClass */
if (\OC::$CLI) { // only store definition if ran from CLI
$this->definition = $definition;
}
}

/**
* @inheritDoc
*
* @return string config key
* @since 31.0.0
*/
public function getKey(): string {
return $this->key;
}

/**
* @inheritDoc
*
* @return ValueType
* @since 31.0.0
*/
public function getValueType(): ValueType {
return $this->type;
}

/**
* @param string $default
* @return string
* @since 31.0.0
*/
private function convertFromString(string $default): string {
return $default;
}

/**
* @param int $default
* @return string
* @since 31.0.0
*/
private function convertFromInt(int $default): string {
return (string)$default;
}

/**
* @param float $default
* @return string
* @since 31.0.0
*/
private function convertFromFloat(float $default): string {
return (string)$default;
}

/**
* @param bool $default
* @return string
* @since 31.0.0
*/
private function convertFromBool(bool $default): string {
return ($default) ? '1' : '0';
}

/**
* @param array $default
* @return string
* @since 31.0.0
*/
private function convertFromArray(array $default): string {
return json_encode($default);
}

/**
* @inheritDoc
*
* @return string|null NULL if no default is set
* @since 31.0.0
*/
public function getDefault(): ?string {
return $this->default;
}

/**
* @inheritDoc
*
* @return string
* @since 31.0.0
*/
public function getDefinition(): string {
return $this->definition;
}

/**
* @inheritDoc
*
* @see IAppConfig for details on lazy config values
* @return bool TRUE if config value is lazy
* @since 31.0.0
*/
public function isLazy(): bool {
return $this->lazy;
}

/**
* @inheritDoc
*
* @see IAppConfig for details on sensitive config values
* @return int bitflag about the config value
* @since 31.0.0
*/
public function getFlags(): int {
return $this->flags;
}

/**
* @param int $flag
*
* @return bool TRUE is config value bitflag contains $flag
* @since 31.0.0
*/
public function isFlagged(int $flag): bool {
return (bool)($flag & $this->getFlags());
}

/**
* @inheritDoc
*
* @return bool TRUE if config si deprecated
* @since 31.0.0
*/
public function isDeprecated(): bool {
return $this->deprecated;
}
}
31 changes: 31 additions & 0 deletions lib/unstable/Config/Lexicon/ConfigLexiconStrictness.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace NCU\Config\Lexicon;

/**
* Strictness regarding using not-listed config keys
*
* - **ConfigLexiconStrictness::IGNORE** - fully ignore
* - **ConfigLexiconStrictness::NOTICE** - ignore and report
* - **ConfigLexiconStrictness::WARNING** - silently block (returns $default) and report
* - **ConfigLexiconStrictness::EXCEPTION** - block (throws exception) and report
*
* @since 31.0.0
* @experimental 31.0.0
*/
enum ConfigLexiconStrictness: int {
/** @since 31.0.0 */
case IGNORE = 0; // fully ignore
/** @since 31.0.0 */
case NOTICE = 2; // ignore and report
/** @since 31.0.0 */
case WARNING = 3; // silently block (returns $default) and report
/** @since 31.0.0 */
case EXCEPTION = 5; // block (throws exception) and report
}
45 changes: 45 additions & 0 deletions lib/unstable/Config/Lexicon/IConfigLexicon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace NCU\Config\Lexicon;

/**
* This interface needs to be implemented if you want to define a config lexicon for your application
* The config lexicon is used to avoid conflicts and problems when storing/retrieving config values
*
* @since 31.0.0
* @experimental 31.0.0
*/
interface IConfigLexicon {

/**
* Define the expected behavior when using config
* keys not set within your application config lexicon.
*
* @see ConfigLexiconStrictness
* @return ConfigLexiconStrictness
* @since 31.0.0
*/
public function getStrictness(): ConfigLexiconStrictness;

/**
* define the list of entries of your application config lexicon, related to AppConfig.
*
* @return ConfigLexiconEntry[]
* @since 31.0.0
*/
public function getAppConfigs(): array;

/**
* define the list of entries of your application config lexicon, related to UserPreferences.
*
* @return ConfigLexiconEntry[]
* @since 31.0.0
*/
public function getUserPreferences(): array;
}
5 changes: 5 additions & 0 deletions tests/lib/AppConfigTest.php
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@

use InvalidArgumentException;
use OC\AppConfig;
use OC\AppFramework\Bootstrap\Coordinator;
use OCP\Exceptions\AppConfigTypeConflictException;
use OCP\Exceptions\AppConfigUnknownKeyException;
use OCP\IAppConfig;
@@ -28,6 +29,8 @@ class AppConfigTest extends TestCase {
protected IDBConnection $connection;
private LoggerInterface $logger;
private ICrypto $crypto;
private Coordinator $coordinator;

private array $originalConfig;

/**
@@ -88,6 +91,7 @@ protected function setUp(): void {
$this->connection = \OCP\Server::get(IDBConnection::class);
$this->logger = \OCP\Server::get(LoggerInterface::class);
$this->crypto = \OCP\Server::get(ICrypto::class);
$this->coordinator = \OCP\Server::get(Coordinator::class);

// storing current config and emptying the data table
$sql = $this->connection->getQueryBuilder();
@@ -178,6 +182,7 @@ private function generateAppConfig(bool $preLoading = true): IAppConfig {
$this->connection,
$this->logger,
$this->crypto,
$this->coordinator
);
$msg = ' generateAppConfig() failed to confirm cache status';

0 comments on commit a9051c2

Please sign in to comment.