config-loader: initial incomplete config schema implementation
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
export { readConfigFile } from './reader';
|
||||
export { readEnvConfig } from './env';
|
||||
export { readSecret } from './secrets';
|
||||
export { loadSchema } from './schema';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user