@backstage/config-loader
Advanced tools
Comparing version 0.0.0-nightly-20209921112 to 0.0.0-nightly-20210625032
# @backstage/config-loader | ||
## 0.0.0-nightly-20209921112 | ||
## 0.0.0-nightly-20210625032 | ||
### Patch Changes | ||
- 60f16c9: Deprecate `$data` and replace it with `$include` which allows for any type of json value to be read from external files. In addition, `$include` can be used without a path, which causes the value at the root of the file to be loaded. | ||
Most usages of `$data` can be directly replaced with `$include`, except if the referenced value is not a string, in which case the value needs to be changed. For example: | ||
```yaml | ||
# app-config.yaml | ||
foo: | ||
$data: foo.yaml#myValue # replacing with $include will turn the value into a number | ||
$data: bar.yaml#myValue # replacing with $include is safe | ||
# foo.yaml | ||
myValue: 0xf00 | ||
# bar.yaml | ||
myValue: bar | ||
``` | ||
## 0.4.0 | ||
### Minor Changes | ||
- 4295a24: Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc. | ||
- 4e7091759: Fix typo of "visibility" in config schema reference | ||
If you have defined a config element named `visiblity`, you | ||
will need to fix the spelling to `visibility`. For more info, | ||
see https://backstage.io/docs/conf/defining#visibility. | ||
### Patch Changes | ||
- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError | ||
## 0.3.0 | ||
### Minor Changes | ||
- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas. | ||
The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project. | ||
A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging. | ||
Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format. | ||
TypeScript configuration schema files should export a single `Config` type, for example: | ||
```ts | ||
export interface Config { | ||
app: { | ||
/** | ||
* Frontend root URL | ||
* @visibility frontend | ||
*/ | ||
baseUrl: string; | ||
}; | ||
} | ||
``` | ||
## 0.2.0 | ||
### Minor Changes | ||
- 8c2b76e45: **BREAKING CHANGE** | ||
The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. | ||
Instead, the CLI and backend process now accept one or more `--config` flags to load config files. | ||
Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. | ||
If passing any `--config <path>` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. | ||
The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: | ||
```bash | ||
--config ../../app-config.yaml --config ../../app-config.development.yaml | ||
``` | ||
- ce5512bc0: Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc. |
@@ -5,29 +5,18 @@ 'use strict'; | ||
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } | ||
var path = require('path'); | ||
var yaml2 = require('yaml'); | ||
var yup = require('yup'); | ||
var Ajv = require('ajv'); | ||
var mergeAllOf = require('json-schema-merge-allof'); | ||
var config = require('@backstage/config'); | ||
var fs = require('fs-extra'); | ||
var fs__default = _interopDefault(fs); | ||
var yaml2 = _interopDefault(require('yaml')); | ||
var yup = require('yup'); | ||
var typescriptJsonSchema = require('typescript-json-schema'); | ||
async function resolveStaticConfig(options) { | ||
const filePaths = [ | ||
`app-config.yaml`, | ||
`app-config.local.yaml`, | ||
`app-config.${options.env}.yaml`, | ||
`app-config.${options.env}.local.yaml` | ||
]; | ||
const resolvedPaths = []; | ||
for (const rootPath of options.rootPaths) { | ||
for (const filePath of filePaths) { | ||
const path2 = path.resolve(rootPath, filePath); | ||
if (await fs.pathExists(path2)) { | ||
resolvedPaths.push(path2); | ||
} | ||
} | ||
} | ||
return resolvedPaths; | ||
} | ||
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } | ||
var yaml2__default = /*#__PURE__*/_interopDefaultLegacy(yaml2); | ||
var Ajv__default = /*#__PURE__*/_interopDefaultLegacy(Ajv); | ||
var mergeAllOf__default = /*#__PURE__*/_interopDefaultLegacy(mergeAllOf); | ||
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs); | ||
function isObject(obj) { | ||
@@ -44,8 +33,5 @@ if (typeof obj !== "object") { | ||
const configYaml = await ctx.readFile(filePath); | ||
const config2 = yaml2.parse(configYaml); | ||
const config2 = yaml2__default['default'].parse(configYaml); | ||
const context = path.basename(filePath); | ||
async function transform(obj, path2) { | ||
if (ctx.skip(path2)) { | ||
return void 0; | ||
} | ||
if (typeof obj !== "object") { | ||
@@ -166,2 +152,5 @@ return obj; | ||
data: yup.string().required() | ||
}), | ||
include: yup.object({ | ||
include: yup.string().required() | ||
}) | ||
@@ -183,4 +172,4 @@ }; | ||
".json": async (content) => JSON.parse(content), | ||
".yaml": async (content) => yaml2.parse(content), | ||
".yml": async (content) => yaml2.parse(content) | ||
".yaml": async (content) => yaml2__default['default'].parse(content), | ||
".yml": async (content) => yaml2__default['default'].parse(content) | ||
}; | ||
@@ -196,2 +185,3 @@ async function readSecret(data, ctx) { | ||
if ("data" in secret) { | ||
console.warn(`Configuration uses deprecated $data key, use $include instead.`); | ||
const url = "path" in secret ? `${secret.data}#${secret.path}` : secret.data; | ||
@@ -219,5 +209,284 @@ const [filePath, dataPath] = url.split(/#(.*)/); | ||
} | ||
if ("include" in secret) { | ||
const [filePath, dataPath] = secret.include.split(/#(.*)/); | ||
const ext = path.extname(filePath); | ||
const parser = dataSecretParser[ext]; | ||
if (!parser) { | ||
throw new Error(`No data secret parser available for extension ${ext}`); | ||
} | ||
const content = await ctx.readFile(filePath); | ||
const parts = dataPath ? dataPath.split(".") : []; | ||
let value; | ||
try { | ||
value = await parser(content); | ||
} catch (error) { | ||
throw new Error(`Failed to parse included file ${filePath}, ${error}`); | ||
} | ||
for (const [index, part] of parts.entries()) { | ||
if (!isObject(value)) { | ||
const errPath = parts.slice(0, index).join("."); | ||
throw new Error(`Value is not an object at ${errPath} in ${filePath}`); | ||
} | ||
value = value[part]; | ||
} | ||
return value; | ||
} | ||
throw new Error("Secret was left unhandled"); | ||
} | ||
const CONFIG_VISIBILITIES = ["frontend", "backend", "secret"]; | ||
const DEFAULT_CONFIG_VISIBILITY = "backend"; | ||
function compileConfigSchemas(schemas) { | ||
const visibilityByPath = new Map(); | ||
const ajv2 = new Ajv__default['default']({ | ||
allErrors: true, | ||
schemas: { | ||
"https://backstage.io/schema/config-v1": true | ||
} | ||
}).addKeyword("visibility", { | ||
metaSchema: { | ||
type: "string", | ||
enum: CONFIG_VISIBILITIES | ||
}, | ||
compile(visibility) { | ||
return (_data, dataPath) => { | ||
if (!dataPath) { | ||
return false; | ||
} | ||
if (visibility && visibility !== "backend") { | ||
const normalizedPath = dataPath.replace(/\['?(.*?)'?\]/g, (_, segment) => `.${segment}`); | ||
visibilityByPath.set(normalizedPath, visibility); | ||
} | ||
return true; | ||
}; | ||
} | ||
}); | ||
const merged = mergeAllOf__default['default']({allOf: schemas.map((_) => _.value)}, { | ||
ignoreAdditionalProperties: true, | ||
resolvers: { | ||
visibility(values, path) { | ||
const hasFrontend = values.some((_) => _ === "frontend"); | ||
const hasSecret = values.some((_) => _ === "secret"); | ||
if (hasFrontend && hasSecret) { | ||
throw new Error(`Config schema visibility is both 'frontend' and 'secret' for ${path.join("/")}`); | ||
} else if (hasFrontend) { | ||
return "frontend"; | ||
} else if (hasSecret) { | ||
return "secret"; | ||
} | ||
return "backend"; | ||
} | ||
} | ||
}); | ||
const validate = ajv2.compile(merged); | ||
return (configs) => { | ||
var _a; | ||
const config2 = config.ConfigReader.fromConfigs(configs).get(); | ||
visibilityByPath.clear(); | ||
const valid = validate(config2); | ||
if (!valid) { | ||
const errors = (_a = validate.errors) != null ? _a : []; | ||
return { | ||
errors: errors.map(({dataPath, message, params}) => { | ||
const paramStr = Object.entries(params).map(([name, value]) => `${name}=${value}`).join(" "); | ||
return `Config ${message || ""} { ${paramStr} } at ${dataPath}`; | ||
}), | ||
visibilityByPath: new Map() | ||
}; | ||
} | ||
return { | ||
visibilityByPath: new Map(visibilityByPath) | ||
}; | ||
}; | ||
} | ||
const req = typeof __non_webpack_require__ === "undefined" ? require : __non_webpack_require__; | ||
async function collectConfigSchemas(packageNames) { | ||
const visitedPackages = new Set(); | ||
const schemas = Array(); | ||
const tsSchemaPaths = Array(); | ||
const currentDir = await fs__default['default'].realpath(process.cwd()); | ||
async function processItem({name, parentPath}) { | ||
var _a, _b; | ||
if (visitedPackages.has(name)) { | ||
return; | ||
} | ||
visitedPackages.add(name); | ||
let pkgPath; | ||
try { | ||
pkgPath = req.resolve(`${name}/package.json`, parentPath && { | ||
paths: [parentPath] | ||
}); | ||
} catch { | ||
return; | ||
} | ||
const pkg = await fs__default['default'].readJson(pkgPath); | ||
const depNames = [ | ||
...Object.keys((_a = pkg.dependencies) != null ? _a : {}), | ||
...Object.keys((_b = pkg.peerDependencies) != null ? _b : {}) | ||
]; | ||
const hasSchema = "configSchema" in pkg; | ||
const hasBackstageDep = depNames.some((_) => _.startsWith("@backstage/")); | ||
if (!hasSchema && !hasBackstageDep) { | ||
return; | ||
} | ||
if (hasSchema) { | ||
if (typeof pkg.configSchema === "string") { | ||
const isJson = pkg.configSchema.endsWith(".json"); | ||
const isDts = pkg.configSchema.endsWith(".d.ts"); | ||
if (!isJson && !isDts) { | ||
throw new Error(`Config schema files must be .json or .d.ts, got ${pkg.configSchema}`); | ||
} | ||
if (isDts) { | ||
tsSchemaPaths.push(path.relative(currentDir, path.resolve(path.dirname(pkgPath), pkg.configSchema))); | ||
} else { | ||
const path2 = path.resolve(path.dirname(pkgPath), pkg.configSchema); | ||
const value = await fs__default['default'].readJson(path2); | ||
schemas.push({ | ||
value, | ||
path: path.relative(currentDir, path2) | ||
}); | ||
} | ||
} else { | ||
schemas.push({ | ||
value: pkg.configSchema, | ||
path: path.relative(currentDir, pkgPath) | ||
}); | ||
} | ||
} | ||
await Promise.all(depNames.map((name2) => processItem({name: name2, parentPath: pkgPath}))); | ||
} | ||
await Promise.all(packageNames.map((name) => processItem({name}))); | ||
const tsSchemas = compileTsSchemas(tsSchemaPaths); | ||
return schemas.concat(tsSchemas); | ||
} | ||
function compileTsSchemas(paths) { | ||
if (paths.length === 0) { | ||
return []; | ||
} | ||
const program = typescriptJsonSchema.getProgramFromFiles(paths, { | ||
incremental: false, | ||
isolatedModules: true, | ||
lib: ["ES5"], | ||
noEmit: true, | ||
noResolve: true, | ||
skipLibCheck: true, | ||
skipDefaultLibCheck: true, | ||
strict: true, | ||
typeRoots: [], | ||
types: [] | ||
}); | ||
const tsSchemas = paths.map((path2) => { | ||
let value; | ||
try { | ||
value = typescriptJsonSchema.generateSchema(program, "Config", { | ||
required: true, | ||
validationKeywords: ["visibility"] | ||
}, [path2.split(path.sep).join("/")]); | ||
} catch (error) { | ||
if (error.message !== "type Config not found") { | ||
throw error; | ||
} | ||
} | ||
if (!value) { | ||
throw new Error(`Invalid schema in ${path2}, missing Config export`); | ||
} | ||
return {path: path2, value}; | ||
}); | ||
return tsSchemas; | ||
} | ||
function filterByVisibility(data, includeVisibilities, visibilityByPath, transformFunc) { | ||
var _a; | ||
function transform(jsonVal, path) { | ||
var _a2; | ||
const visibility = (_a2 = visibilityByPath.get(path)) != null ? _a2 : DEFAULT_CONFIG_VISIBILITY; | ||
const isVisible = includeVisibilities.includes(visibility); | ||
if (typeof jsonVal !== "object") { | ||
if (isVisible) { | ||
if (transformFunc) { | ||
return transformFunc(jsonVal, {visibility}); | ||
} | ||
return jsonVal; | ||
} | ||
return void 0; | ||
} else if (jsonVal === null) { | ||
return void 0; | ||
} else if (Array.isArray(jsonVal)) { | ||
const arr = new Array(); | ||
for (const [index, value] of jsonVal.entries()) { | ||
const out = transform(value, `${path}.${index}`); | ||
if (out !== void 0) { | ||
arr.push(out); | ||
} | ||
} | ||
if (arr.length > 0 || isVisible) { | ||
return arr; | ||
} | ||
return void 0; | ||
} | ||
const outObj = {}; | ||
let hasOutput = false; | ||
for (const [key, value] of Object.entries(jsonVal)) { | ||
if (value === void 0) { | ||
continue; | ||
} | ||
const out = transform(value, `${path}.${key}`); | ||
if (out !== void 0) { | ||
outObj[key] = out; | ||
hasOutput = true; | ||
} | ||
} | ||
if (hasOutput || isVisible) { | ||
return outObj; | ||
} | ||
return void 0; | ||
} | ||
return (_a = transform(data, "")) != null ? _a : {}; | ||
} | ||
async function loadConfigSchema(options) { | ||
let schemas; | ||
if ("dependencies" in options) { | ||
schemas = await collectConfigSchemas(options.dependencies); | ||
} else { | ||
const {serialized} = options; | ||
if ((serialized == null ? void 0 : serialized.backstageConfigSchemaVersion) !== 1) { | ||
throw new Error("Serialized configuration schema is invalid or has an invalid version number"); | ||
} | ||
schemas = serialized.schemas; | ||
} | ||
const validate = compileConfigSchemas(schemas); | ||
return { | ||
process(configs, {visibility, valueTransform} = {}) { | ||
const result = validate(configs); | ||
if (result.errors) { | ||
const error = new Error(`Config validation failed, ${result.errors.join("; ")}`); | ||
error.messages = result.errors; | ||
throw error; | ||
} | ||
let processedConfigs = configs; | ||
if (visibility) { | ||
processedConfigs = processedConfigs.map(({data, context}) => ({ | ||
context, | ||
data: filterByVisibility(data, visibility, result.visibilityByPath, valueTransform) | ||
})); | ||
} else if (valueTransform) { | ||
processedConfigs = processedConfigs.map(({data, context}) => ({ | ||
context, | ||
data: filterByVisibility(data, Array.from(CONFIG_VISIBILITIES), result.visibilityByPath, valueTransform) | ||
})); | ||
} | ||
return processedConfigs; | ||
}, | ||
serialize() { | ||
return { | ||
schemas, | ||
backstageConfigSchemaVersion: 1 | ||
}; | ||
} | ||
}; | ||
} | ||
class Context { | ||
@@ -230,16 +499,6 @@ constructor(options) { | ||
} | ||
skip(path2) { | ||
if (this.options.shouldReadSecrets) { | ||
return false; | ||
} | ||
return this.options.secretPaths.has(path2); | ||
} | ||
async readFile(path2) { | ||
return fs__default.readFile(path.resolve(this.options.rootPath, path2), "utf8"); | ||
return fs__default['default'].readFile(path.resolve(this.options.rootPath, path2), "utf8"); | ||
} | ||
async readSecret(path2, desc) { | ||
this.options.secretPaths.add(path2); | ||
if (!this.options.shouldReadSecrets) { | ||
return void 0; | ||
} | ||
async readSecret(_path, desc) { | ||
return readSecret(desc, this); | ||
@@ -250,11 +509,24 @@ } | ||
const configs = []; | ||
const configPaths = await resolveStaticConfig(options); | ||
const {configRoot} = options; | ||
const configPaths = options.configPaths.slice(); | ||
if (configPaths.length === 0) { | ||
configPaths.push(path.resolve(configRoot, "app-config.yaml")); | ||
const localConfig = path.resolve(configRoot, "app-config.local.yaml"); | ||
if (await fs__default['default'].pathExists(localConfig)) { | ||
configPaths.push(localConfig); | ||
} | ||
const envFile = `app-config.${options.env}.yaml`; | ||
if (await fs__default['default'].pathExists(path.resolve(configRoot, envFile))) { | ||
console.error(`Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`); | ||
console.error(`To load the config file, use --config <path>, listing every config file that you want to load`); | ||
} | ||
} | ||
try { | ||
const secretPaths = new Set(); | ||
for (const configPath of configPaths) { | ||
if (!path.isAbsolute(configPath)) { | ||
throw new Error(`Config load path is not absolute: '${configPath}'`); | ||
} | ||
const config2 = await readConfigFile(configPath, new Context({ | ||
secretPaths, | ||
env: process.env, | ||
rootPath: path.dirname(configPath), | ||
shouldReadSecrets: Boolean(options.shouldReadSecrets) | ||
rootPath: path.dirname(configPath) | ||
})); | ||
@@ -271,3 +543,4 @@ configs.push(config2); | ||
exports.loadConfig = loadConfig; | ||
exports.loadConfigSchema = loadConfigSchema; | ||
exports.readEnvConfig = readEnvConfig; | ||
//# sourceMappingURL=index.cjs.js.map |
@@ -1,2 +0,2 @@ | ||
import { AppConfig } from '@backstage/config'; | ||
import { AppConfig, JsonObject } from '@backstage/config'; | ||
@@ -25,9 +25,58 @@ /** | ||
/** | ||
* A list of all possible configuration value visibilities. | ||
*/ | ||
declare const CONFIG_VISIBILITIES: readonly ["frontend", "backend", "secret"]; | ||
/** | ||
* A type representing the possible configuration value visibilities | ||
*/ | ||
declare type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; | ||
/** | ||
* A function used to transform primitive configuration values. | ||
*/ | ||
declare type TransformFunc<T extends number | string | boolean> = (value: T, context: { | ||
visibility: ConfigVisibility; | ||
}) => T | undefined; | ||
/** | ||
* Options used to process configuration data with a schema. | ||
*/ | ||
declare type ConfigProcessingOptions = { | ||
/** | ||
* The visibilities that should be included in the output data. | ||
* If omitted, the data will not be filtered by visibility. | ||
*/ | ||
visibility?: ConfigVisibility[]; | ||
/** | ||
* A transform function that can be used to transform primitive configuration values | ||
* during validation. The value returned from the transform function will be used | ||
* instead of the original value. If the transform returns `undefined`, the value | ||
* will be omitted. | ||
*/ | ||
valueTransform?: TransformFunc<any>; | ||
}; | ||
/** | ||
* A loaded configuration schema that is ready to process configuration data. | ||
*/ | ||
declare type ConfigSchema = { | ||
process(appConfigs: AppConfig[], options?: ConfigProcessingOptions): AppConfig[]; | ||
serialize(): JsonObject; | ||
}; | ||
declare type Options = { | ||
dependencies: string[]; | ||
} | { | ||
serialized: JsonObject; | ||
}; | ||
/** | ||
* Loads config schema for a Backstage instance. | ||
*/ | ||
declare function loadConfigSchema(options: Options): Promise<ConfigSchema>; | ||
declare type LoadConfigOptions = { | ||
rootPaths: string[]; | ||
configRoot: string; | ||
configPaths: string[]; | ||
env: string; | ||
shouldReadSecrets?: boolean; | ||
}; | ||
declare function loadConfig(options: LoadConfigOptions): Promise<AppConfig[]>; | ||
export { LoadConfigOptions, loadConfig, readEnvConfig }; | ||
export { ConfigSchema, ConfigVisibility, LoadConfigOptions, loadConfig, loadConfigSchema, readEnvConfig }; |
@@ -1,25 +0,10 @@ | ||
import { resolve, basename, extname, dirname } from 'path'; | ||
import fs, { pathExists } from 'fs-extra'; | ||
import { basename, extname, sep, relative, resolve, dirname, isAbsolute } from 'path'; | ||
import yaml2 from 'yaml'; | ||
import { object, string, lazy, ValidationError } from 'yup'; | ||
import Ajv from 'ajv'; | ||
import mergeAllOf from 'json-schema-merge-allof'; | ||
import { ConfigReader } from '@backstage/config'; | ||
import fs from 'fs-extra'; | ||
import { getProgramFromFiles, generateSchema } from 'typescript-json-schema'; | ||
async function resolveStaticConfig(options) { | ||
const filePaths = [ | ||
`app-config.yaml`, | ||
`app-config.local.yaml`, | ||
`app-config.${options.env}.yaml`, | ||
`app-config.${options.env}.local.yaml` | ||
]; | ||
const resolvedPaths = []; | ||
for (const rootPath of options.rootPaths) { | ||
for (const filePath of filePaths) { | ||
const path2 = resolve(rootPath, filePath); | ||
if (await pathExists(path2)) { | ||
resolvedPaths.push(path2); | ||
} | ||
} | ||
} | ||
return resolvedPaths; | ||
} | ||
function isObject(obj) { | ||
@@ -39,5 +24,2 @@ if (typeof obj !== "object") { | ||
async function transform(obj, path2) { | ||
if (ctx.skip(path2)) { | ||
return void 0; | ||
} | ||
if (typeof obj !== "object") { | ||
@@ -158,2 +140,5 @@ return obj; | ||
data: string().required() | ||
}), | ||
include: object({ | ||
include: string().required() | ||
}) | ||
@@ -187,2 +172,3 @@ }; | ||
if ("data" in secret) { | ||
console.warn(`Configuration uses deprecated $data key, use $include instead.`); | ||
const url = "path" in secret ? `${secret.data}#${secret.path}` : secret.data; | ||
@@ -210,5 +196,284 @@ const [filePath, dataPath] = url.split(/#(.*)/); | ||
} | ||
if ("include" in secret) { | ||
const [filePath, dataPath] = secret.include.split(/#(.*)/); | ||
const ext = extname(filePath); | ||
const parser = dataSecretParser[ext]; | ||
if (!parser) { | ||
throw new Error(`No data secret parser available for extension ${ext}`); | ||
} | ||
const content = await ctx.readFile(filePath); | ||
const parts = dataPath ? dataPath.split(".") : []; | ||
let value; | ||
try { | ||
value = await parser(content); | ||
} catch (error) { | ||
throw new Error(`Failed to parse included file ${filePath}, ${error}`); | ||
} | ||
for (const [index, part] of parts.entries()) { | ||
if (!isObject(value)) { | ||
const errPath = parts.slice(0, index).join("."); | ||
throw new Error(`Value is not an object at ${errPath} in ${filePath}`); | ||
} | ||
value = value[part]; | ||
} | ||
return value; | ||
} | ||
throw new Error("Secret was left unhandled"); | ||
} | ||
const CONFIG_VISIBILITIES = ["frontend", "backend", "secret"]; | ||
const DEFAULT_CONFIG_VISIBILITY = "backend"; | ||
function compileConfigSchemas(schemas) { | ||
const visibilityByPath = new Map(); | ||
const ajv2 = new Ajv({ | ||
allErrors: true, | ||
schemas: { | ||
"https://backstage.io/schema/config-v1": true | ||
} | ||
}).addKeyword("visibility", { | ||
metaSchema: { | ||
type: "string", | ||
enum: CONFIG_VISIBILITIES | ||
}, | ||
compile(visibility) { | ||
return (_data, dataPath) => { | ||
if (!dataPath) { | ||
return false; | ||
} | ||
if (visibility && visibility !== "backend") { | ||
const normalizedPath = dataPath.replace(/\['?(.*?)'?\]/g, (_, segment) => `.${segment}`); | ||
visibilityByPath.set(normalizedPath, visibility); | ||
} | ||
return true; | ||
}; | ||
} | ||
}); | ||
const merged = mergeAllOf({allOf: schemas.map((_) => _.value)}, { | ||
ignoreAdditionalProperties: true, | ||
resolvers: { | ||
visibility(values, path) { | ||
const hasFrontend = values.some((_) => _ === "frontend"); | ||
const hasSecret = values.some((_) => _ === "secret"); | ||
if (hasFrontend && hasSecret) { | ||
throw new Error(`Config schema visibility is both 'frontend' and 'secret' for ${path.join("/")}`); | ||
} else if (hasFrontend) { | ||
return "frontend"; | ||
} else if (hasSecret) { | ||
return "secret"; | ||
} | ||
return "backend"; | ||
} | ||
} | ||
}); | ||
const validate = ajv2.compile(merged); | ||
return (configs) => { | ||
var _a; | ||
const config2 = ConfigReader.fromConfigs(configs).get(); | ||
visibilityByPath.clear(); | ||
const valid = validate(config2); | ||
if (!valid) { | ||
const errors = (_a = validate.errors) != null ? _a : []; | ||
return { | ||
errors: errors.map(({dataPath, message, params}) => { | ||
const paramStr = Object.entries(params).map(([name, value]) => `${name}=${value}`).join(" "); | ||
return `Config ${message || ""} { ${paramStr} } at ${dataPath}`; | ||
}), | ||
visibilityByPath: new Map() | ||
}; | ||
} | ||
return { | ||
visibilityByPath: new Map(visibilityByPath) | ||
}; | ||
}; | ||
} | ||
const req = typeof __non_webpack_require__ === "undefined" ? require : __non_webpack_require__; | ||
async function collectConfigSchemas(packageNames) { | ||
const visitedPackages = new Set(); | ||
const schemas = Array(); | ||
const tsSchemaPaths = Array(); | ||
const currentDir = await fs.realpath(process.cwd()); | ||
async function processItem({name, parentPath}) { | ||
var _a, _b; | ||
if (visitedPackages.has(name)) { | ||
return; | ||
} | ||
visitedPackages.add(name); | ||
let pkgPath; | ||
try { | ||
pkgPath = req.resolve(`${name}/package.json`, parentPath && { | ||
paths: [parentPath] | ||
}); | ||
} catch { | ||
return; | ||
} | ||
const pkg = await fs.readJson(pkgPath); | ||
const depNames = [ | ||
...Object.keys((_a = pkg.dependencies) != null ? _a : {}), | ||
...Object.keys((_b = pkg.peerDependencies) != null ? _b : {}) | ||
]; | ||
const hasSchema = "configSchema" in pkg; | ||
const hasBackstageDep = depNames.some((_) => _.startsWith("@backstage/")); | ||
if (!hasSchema && !hasBackstageDep) { | ||
return; | ||
} | ||
if (hasSchema) { | ||
if (typeof pkg.configSchema === "string") { | ||
const isJson = pkg.configSchema.endsWith(".json"); | ||
const isDts = pkg.configSchema.endsWith(".d.ts"); | ||
if (!isJson && !isDts) { | ||
throw new Error(`Config schema files must be .json or .d.ts, got ${pkg.configSchema}`); | ||
} | ||
if (isDts) { | ||
tsSchemaPaths.push(relative(currentDir, resolve(dirname(pkgPath), pkg.configSchema))); | ||
} else { | ||
const path2 = resolve(dirname(pkgPath), pkg.configSchema); | ||
const value = await fs.readJson(path2); | ||
schemas.push({ | ||
value, | ||
path: relative(currentDir, path2) | ||
}); | ||
} | ||
} else { | ||
schemas.push({ | ||
value: pkg.configSchema, | ||
path: relative(currentDir, pkgPath) | ||
}); | ||
} | ||
} | ||
await Promise.all(depNames.map((name2) => processItem({name: name2, parentPath: pkgPath}))); | ||
} | ||
await Promise.all(packageNames.map((name) => processItem({name}))); | ||
const tsSchemas = compileTsSchemas(tsSchemaPaths); | ||
return schemas.concat(tsSchemas); | ||
} | ||
function compileTsSchemas(paths) { | ||
if (paths.length === 0) { | ||
return []; | ||
} | ||
const program = getProgramFromFiles(paths, { | ||
incremental: false, | ||
isolatedModules: true, | ||
lib: ["ES5"], | ||
noEmit: true, | ||
noResolve: true, | ||
skipLibCheck: true, | ||
skipDefaultLibCheck: true, | ||
strict: true, | ||
typeRoots: [], | ||
types: [] | ||
}); | ||
const tsSchemas = paths.map((path2) => { | ||
let value; | ||
try { | ||
value = generateSchema(program, "Config", { | ||
required: true, | ||
validationKeywords: ["visibility"] | ||
}, [path2.split(sep).join("/")]); | ||
} catch (error) { | ||
if (error.message !== "type Config not found") { | ||
throw error; | ||
} | ||
} | ||
if (!value) { | ||
throw new Error(`Invalid schema in ${path2}, missing Config export`); | ||
} | ||
return {path: path2, value}; | ||
}); | ||
return tsSchemas; | ||
} | ||
function filterByVisibility(data, includeVisibilities, visibilityByPath, transformFunc) { | ||
var _a; | ||
function transform(jsonVal, path) { | ||
var _a2; | ||
const visibility = (_a2 = visibilityByPath.get(path)) != null ? _a2 : DEFAULT_CONFIG_VISIBILITY; | ||
const isVisible = includeVisibilities.includes(visibility); | ||
if (typeof jsonVal !== "object") { | ||
if (isVisible) { | ||
if (transformFunc) { | ||
return transformFunc(jsonVal, {visibility}); | ||
} | ||
return jsonVal; | ||
} | ||
return void 0; | ||
} else if (jsonVal === null) { | ||
return void 0; | ||
} else if (Array.isArray(jsonVal)) { | ||
const arr = new Array(); | ||
for (const [index, value] of jsonVal.entries()) { | ||
const out = transform(value, `${path}.${index}`); | ||
if (out !== void 0) { | ||
arr.push(out); | ||
} | ||
} | ||
if (arr.length > 0 || isVisible) { | ||
return arr; | ||
} | ||
return void 0; | ||
} | ||
const outObj = {}; | ||
let hasOutput = false; | ||
for (const [key, value] of Object.entries(jsonVal)) { | ||
if (value === void 0) { | ||
continue; | ||
} | ||
const out = transform(value, `${path}.${key}`); | ||
if (out !== void 0) { | ||
outObj[key] = out; | ||
hasOutput = true; | ||
} | ||
} | ||
if (hasOutput || isVisible) { | ||
return outObj; | ||
} | ||
return void 0; | ||
} | ||
return (_a = transform(data, "")) != null ? _a : {}; | ||
} | ||
async function loadConfigSchema(options) { | ||
let schemas; | ||
if ("dependencies" in options) { | ||
schemas = await collectConfigSchemas(options.dependencies); | ||
} else { | ||
const {serialized} = options; | ||
if ((serialized == null ? void 0 : serialized.backstageConfigSchemaVersion) !== 1) { | ||
throw new Error("Serialized configuration schema is invalid or has an invalid version number"); | ||
} | ||
schemas = serialized.schemas; | ||
} | ||
const validate = compileConfigSchemas(schemas); | ||
return { | ||
process(configs, {visibility, valueTransform} = {}) { | ||
const result = validate(configs); | ||
if (result.errors) { | ||
const error = new Error(`Config validation failed, ${result.errors.join("; ")}`); | ||
error.messages = result.errors; | ||
throw error; | ||
} | ||
let processedConfigs = configs; | ||
if (visibility) { | ||
processedConfigs = processedConfigs.map(({data, context}) => ({ | ||
context, | ||
data: filterByVisibility(data, visibility, result.visibilityByPath, valueTransform) | ||
})); | ||
} else if (valueTransform) { | ||
processedConfigs = processedConfigs.map(({data, context}) => ({ | ||
context, | ||
data: filterByVisibility(data, Array.from(CONFIG_VISIBILITIES), result.visibilityByPath, valueTransform) | ||
})); | ||
} | ||
return processedConfigs; | ||
}, | ||
serialize() { | ||
return { | ||
schemas, | ||
backstageConfigSchemaVersion: 1 | ||
}; | ||
} | ||
}; | ||
} | ||
class Context { | ||
@@ -221,16 +486,6 @@ constructor(options) { | ||
} | ||
skip(path2) { | ||
if (this.options.shouldReadSecrets) { | ||
return false; | ||
} | ||
return this.options.secretPaths.has(path2); | ||
} | ||
async readFile(path2) { | ||
return fs.readFile(resolve(this.options.rootPath, path2), "utf8"); | ||
} | ||
async readSecret(path2, desc) { | ||
this.options.secretPaths.add(path2); | ||
if (!this.options.shouldReadSecrets) { | ||
return void 0; | ||
} | ||
async readSecret(_path, desc) { | ||
return readSecret(desc, this); | ||
@@ -241,11 +496,24 @@ } | ||
const configs = []; | ||
const configPaths = await resolveStaticConfig(options); | ||
const {configRoot} = options; | ||
const configPaths = options.configPaths.slice(); | ||
if (configPaths.length === 0) { | ||
configPaths.push(resolve(configRoot, "app-config.yaml")); | ||
const localConfig = resolve(configRoot, "app-config.local.yaml"); | ||
if (await fs.pathExists(localConfig)) { | ||
configPaths.push(localConfig); | ||
} | ||
const envFile = `app-config.${options.env}.yaml`; | ||
if (await fs.pathExists(resolve(configRoot, envFile))) { | ||
console.error(`Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`); | ||
console.error(`To load the config file, use --config <path>, listing every config file that you want to load`); | ||
} | ||
} | ||
try { | ||
const secretPaths = new Set(); | ||
for (const configPath of configPaths) { | ||
if (!isAbsolute(configPath)) { | ||
throw new Error(`Config load path is not absolute: '${configPath}'`); | ||
} | ||
const config2 = await readConfigFile(configPath, new Context({ | ||
secretPaths, | ||
env: process.env, | ||
rootPath: dirname(configPath), | ||
shouldReadSecrets: Boolean(options.shouldReadSecrets) | ||
rootPath: dirname(configPath) | ||
})); | ||
@@ -261,3 +529,3 @@ configs.push(config2); | ||
export { loadConfig, readEnvConfig }; | ||
export { loadConfig, loadConfigSchema, readEnvConfig }; | ||
//# sourceMappingURL=index.esm.js.map |
{ | ||
"name": "@backstage/config-loader", | ||
"description": "Config loading functionality used by Backstage backend, and CLI", | ||
"version": "0.0.0-nightly-20209921112", | ||
"version": "0.0.0-nightly-20210625032", | ||
"private": false, | ||
@@ -15,3 +15,3 @@ "publishConfig": { | ||
"type": "git", | ||
"url": "https://github.com/spotify/backstage", | ||
"url": "https://github.com/backstage/backstage", | ||
"directory": "packages/config-loader" | ||
@@ -34,12 +34,19 @@ }, | ||
"dependencies": { | ||
"@backstage/config": "^0.1.1-alpha.24", | ||
"@backstage/cli-common": "^0.1.1", | ||
"@backstage/config": "^0.1.1", | ||
"ajv": "^6.12.5", | ||
"fs-extra": "^9.0.0", | ||
"json-schema": "^0.2.5", | ||
"json-schema-merge-allof": "^0.7.0", | ||
"typescript-json-schema": "^0.45.0", | ||
"yaml": "^1.9.2", | ||
"yup": "^0.29.1" | ||
"yup": "^0.29.3" | ||
}, | ||
"devDependencies": { | ||
"@types/jest": "^26.0.7", | ||
"@types/json-schema": "^7.0.6", | ||
"@types/json-schema-merge-allof": "^0.6.0", | ||
"@types/mock-fs": "^4.10.0", | ||
"@types/node": "^12.0.0", | ||
"@types/yup": "^0.28.2", | ||
"@types/yup": "^0.29.8", | ||
"mock-fs": "^4.13.0" | ||
@@ -46,0 +53,0 @@ }, |
@@ -11,3 +11,3 @@ # @backstage/config-loader | ||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) | ||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) | ||
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) | ||
- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
131018
1111
9
7
8
+ Added@backstage/cli-common@^0.1.1
+ Addedajv@^6.12.5
+ Addedjson-schema@^0.2.5
+ Added@backstage/cli-common@0.1.15(transitive)
+ Added@types/json-schema@7.0.15(transitive)
+ Addedajv@6.12.6(transitive)
+ Addedansi-regex@5.0.1(transitive)
+ Addedansi-styles@4.3.0(transitive)
+ Addedbalanced-match@1.0.2(transitive)
+ Addedbrace-expansion@1.1.11(transitive)
+ Addedcall-bind@1.0.8(transitive)
+ Addedcall-bind-apply-helpers@1.0.1(transitive)
+ Addedcall-bound@1.0.3(transitive)
+ Addedcliui@7.0.4(transitive)
+ Addedcolor-convert@2.0.1(transitive)
+ Addedcolor-name@1.1.4(transitive)
+ Addedcompute-gcd@1.2.1(transitive)
+ Addedcompute-lcm@1.1.2(transitive)
+ Addedconcat-map@0.0.1(transitive)
+ Addeddefine-data-property@1.1.4(transitive)
+ Addeddunder-proto@1.0.1(transitive)
+ Addedemoji-regex@8.0.0(transitive)
+ Addedes-define-property@1.0.1(transitive)
+ Addedes-errors@1.3.0(transitive)
+ Addedes-object-atoms@1.0.0(transitive)
+ Addedescalade@3.2.0(transitive)
+ Addedfast-deep-equal@3.1.3(transitive)
+ Addedfast-json-stable-stringify@2.1.0(transitive)
+ Addedfs.realpath@1.0.0(transitive)
+ Addedfunction-bind@1.1.2(transitive)
+ Addedget-caller-file@2.0.5(transitive)
+ Addedget-intrinsic@1.2.6(transitive)
+ Addedglob@7.2.3(transitive)
+ Addedgopd@1.2.0(transitive)
+ Addedhas-property-descriptors@1.0.2(transitive)
+ Addedhas-symbols@1.1.0(transitive)
+ Addedhasown@2.0.2(transitive)
+ Addedinflight@1.0.6(transitive)
+ Addedinherits@2.0.4(transitive)
+ Addedis-fullwidth-code-point@3.0.0(transitive)
+ Addedisarray@2.0.5(transitive)
+ Addedjson-schema@0.2.5(transitive)
+ Addedjson-schema-compare@0.2.2(transitive)
+ Addedjson-schema-merge-allof@0.7.0(transitive)
+ Addedjson-schema-traverse@0.4.1(transitive)
+ Addedjson-stable-stringify@1.2.0(transitive)
+ Addedjsonify@0.0.1(transitive)
+ Addedmath-intrinsics@1.0.0(transitive)
+ Addedminimatch@3.1.2(transitive)
+ Addedobject-keys@1.1.1(transitive)
+ Addedonce@1.4.0(transitive)
+ Addedpath-is-absolute@1.0.1(transitive)
+ Addedpunycode@2.3.1(transitive)
+ Addedrequire-directory@2.1.1(transitive)
+ Addedset-function-length@1.2.2(transitive)
+ Addedstring-width@4.2.3(transitive)
+ Addedstrip-ansi@6.0.1(transitive)
+ Addedtypescript@4.9.5(transitive)
+ Addedtypescript-json-schema@0.45.1(transitive)
+ Addeduri-js@4.4.1(transitive)
+ Addedvalidate.io-array@1.0.6(transitive)
+ Addedvalidate.io-function@1.0.2(transitive)
+ Addedvalidate.io-integer@1.0.5(transitive)
+ Addedvalidate.io-integer-array@1.0.0(transitive)
+ Addedvalidate.io-number@1.0.3(transitive)
+ Addedwrap-ansi@7.0.0(transitive)
+ Addedwrappy@1.0.2(transitive)
+ Addedy18n@5.0.8(transitive)
+ Addedyargs@16.2.0(transitive)
+ Addedyargs-parser@20.2.9(transitive)
Updated@backstage/config@^0.1.1
Updatedyup@^0.29.3