config-loader: initial incomplete config schema implementation

This commit is contained in:
Patrik Oldsberg
2020-11-07 18:08:19 +01:00
parent ddb5a38f77
commit 6fec95079a
5 changed files with 261 additions and 4 deletions
+6
View File
@@ -30,8 +30,14 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli-common": "^0.1.1",
"@backstage/config": "^0.1.1",
"@types/json-schema": "^7.0.6",
"@types/json-schema-merge-allof": "^0.6.0",
"ajv": "^7.0.0-beta.2",
"fs-extra": "^9.0.0",
"json-schema": "^0.2.5",
"json-schema-merge-allof": "^0.7.0",
"yaml": "^1.9.2",
"yup": "^0.29.3"
},
+1
View File
@@ -17,3 +17,4 @@
export { readConfigFile } from './reader';
export { readEnvConfig } from './env';
export { readSecret } from './secrets';
export { loadSchema } from './schema';
+216
View File
@@ -0,0 +1,216 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { resolve as resolvePath, dirname } from 'path';
import Ajv from 'ajv';
import { JSONSchema7 as JSONSchema } from 'json-schema';
import mergeAllOf, { Resolvers } from 'json-schema-merge-allof';
import { AppConfig, ConfigReader } from '@backstage/config';
const CONFIG_VISIBILITIES = ['frontend', 'backend', 'secret'] as const;
type ConfigVisibility = typeof CONFIG_VISIBILITIES[number];
type ConfigSchema = {
load(appConfigs: AppConfig[]): AppConfig[];
};
type ConfigSchemaPackageEntry = {
value: JSONSchema;
path: string;
};
type Options = {
dependencies: string[];
};
type ValidationError = string;
type ValidationResult = {
errors?: ValidationError[];
visibilities: Map<string, ConfigVisibility>;
};
type ValidationFunc = (configs: AppConfig[]) => ValidationResult;
export async function loadSchema(options: Options): Promise<ConfigSchema> {
const start = process.hrtime();
const schemas = await collectConfigSchemas(options.dependencies[0]);
const [durS, durNs] = process.hrtime(start);
const dur = (durS + durNs / 10 ** 9).toFixed(3);
console.log(`DEBUG: collected config schemas in ${dur}s`);
console.log('DEBUG: schemas =', schemas);
const validate = compileConfigSchemas(schemas);
return {
load(configs: AppConfig[]): AppConfig[] {
const result = validate(configs);
console.log('DEBUG: result =', result);
if (result.errors) {
throw new Error(
`Config validation failed, ${result.errors.join('; ')}`,
);
}
return configs;
},
};
}
function compileConfigSchemas(
schemas: ConfigSchemaPackageEntry[],
): ValidationFunc {
const visibilities = new Map<string, ConfigVisibility>();
const ajv = new Ajv({
strict: true,
allErrors: true,
defaultMeta: 'http://json-schema.org/draft-07/schema#',
schemas: {
'https://backstage.io/schema/config-v1': true,
},
keywords: [
{
keyword: 'visibility',
schemaType: 'string',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, ctx) => {
if (!ctx) {
return false;
}
if (visibility === 'frontend') {
visibilities.set(ctx.dataPath, visibility);
}
return true;
};
},
},
],
});
const merged = mergeAllOf(
{ allOf: schemas.map(_ => _.value) },
{
ignoreAdditionalProperties: true,
resolvers: {
visibility(values: string[], path: string[]) {
const hasApp = values.some(_ => _ === 'frontend');
const hasSecret = values.some(_ => _ === 'secret');
if (hasApp && hasSecret) {
throw new Error(
`Config schema visibility is both 'frontend' and 'secret' for ${path.join(
'/',
)}`,
);
} else if (hasApp) {
return 'frontend';
} else if (hasSecret) {
return 'secret';
}
return 'backend';
},
} as Partial<Resolvers<JSONSchema>>,
},
);
const validate = ajv.compile(merged);
return configs => {
const config = ConfigReader.fromConfigs(configs).get();
visibilities.clear();
const valid = validate(config);
if (!valid) {
const errors = ajv.errorsText(validate.errors);
return {
errors: [errors],
visibilities: new Map(),
};
}
return {
visibilities: new Map(visibilities),
};
};
}
const req =
typeof __non_webpack_require__ === 'undefined'
? require
: __non_webpack_require__;
async function collectConfigSchemas(
name: string,
opts: unknown = {},
visited: Set<string> = new Set(),
schemas: ConfigSchemaPackageEntry[] = [],
): Promise<ConfigSchemaPackageEntry[]> {
const pkgPath = req.resolve(`${name}/package.json`, opts);
if (visited.has(pkgPath)) {
return schemas;
}
visited.add(pkgPath);
const pkg = await fs.readJson(pkgPath);
const depNames = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
];
// TODO(Rugvip): Trying this out to avoid having to traverse the full dependency graph,
// since that's pretty slow. We probably need a better way to determine when
// we've left the Backstage ecosystem, but this will do for now.
const hasSchema = 'configSchema' in pkg;
const hasBackstageDep = depNames.some(_ => _.startsWith('@backstage/'));
if (!hasSchema && !hasBackstageDep) {
return schemas;
}
if (hasSchema) {
if (typeof pkg.configSchema === 'string') {
if (!pkg.configSchema.endsWith('.json')) {
throw new Error(
`Config schema files must be .json, got ${pkg.configSchema}`,
);
}
const value = await fs.readJson(
resolvePath(dirname(pkgPath), pkg.configSchema),
);
schemas.push({
value,
path: pkgPath,
});
} else {
schemas.push({
value: pkg.configSchema,
path: pkgPath,
});
}
}
for (const depName of depNames) {
await collectConfigSchemas(depName, { paths: [pkgPath] }, visited, schemas);
}
return schemas;
}
+7 -3
View File
@@ -17,7 +17,7 @@
import fs from 'fs-extra';
import { resolve as resolvePath, dirname, isAbsolute } from 'path';
import { AppConfig, JsonObject } from '@backstage/config';
import { readConfigFile, readEnvConfig, readSecret } from './lib';
import { readConfigFile, readEnvConfig, readSecret, loadSchema } from './lib';
export type LoadConfigOptions = {
// The root directory of the config loading context. Used to find default configs.
@@ -71,13 +71,17 @@ class Context {
}
}
type LoadedConfig = AppConfig[];
export async function loadConfig(
options: LoadConfigOptions,
): Promise<AppConfig[]> {
): Promise<LoadedConfig> {
const configs = [];
const { configRoot } = options;
const configPaths = options.configPaths.slice();
const schema = await loadSchema({ dependencies: ['example-backend'] });
// If no paths are provided, we default to reading
// `app-config.yaml` and, if it exists, `app-config.local.yaml`
if (configPaths.length === 0) {
@@ -126,5 +130,5 @@ export async function loadConfig(
configs.push(...readEnvConfig(process.env));
return configs;
return schema.load(configs);
}
+31 -1
View File
@@ -5369,7 +5369,14 @@
resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb"
integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww==
"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5":
"@types/json-schema-merge-allof@^0.6.0":
version "0.6.0"
resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#0f587d8a3bcb41a55ef2e91d3ba96430c9bc1813"
integrity sha512-v6iCEk4Sxy1twlCTtrRxMqSHX0vuLJ7Ql4hiIUZRMOswxtlUeybIfYaZIj7pX747RBczG2YtBm4Fn6sqKzeY2Q==
dependencies:
"@types/json-schema" "*"
"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6":
version "7.0.6"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
@@ -6562,6 +6569,15 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^7.0.0-beta.2:
version "7.0.0-beta.3"
resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.0-beta.3.tgz#d34861ccfbdebb55bf9f49a08b29f76bf656fc5c"
integrity sha512-gUGVvM4NmyqrFvCNAQnP4P7FC0RjxMQyRnrXpozNglBkDJnTysVbvycyOZUy5n6yLKSqVDUqWZBXj7dXINrSqw==
dependencies:
fast-deep-equal "^3.1.1"
json-schema-traverse "^0.5.0"
uri-js "^4.2.2"
alphanum-sort@^1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -15022,11 +15038,25 @@ json-schema-merge-allof@^0.6.0:
json-schema-compare "^0.2.2"
lodash "^4.17.4"
json-schema-merge-allof@^0.7.0:
version "0.7.0"
resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.7.0.tgz#84d3e8c3e03d3060014286958eb8834fa9d76304"
integrity sha512-kvsuSVnl1n5xnNEu5ed4o8r8ujSA4/IgRtHmpgfMfa7FOMIRAzN4F9qbuklouTn5J8bi83y6MQ11n+ERMMTXZg==
dependencies:
compute-lcm "^1.1.0"
json-schema-compare "^0.2.2"
lodash "^4.17.4"
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-schema-traverse@^0.5.0:
version "0.5.0"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.5.0.tgz#1069a6097f9e9a567bfc6cc215e85f606ebf6480"
integrity sha512-x+TRJIQFskrNnFKE2Viz9FCSjK1vIh+H/uaBiOYszh/IcZmAFneQ35H4osWDJp1NPXccuV2I0RMXmi2ZS6Kqcg==
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"