@backstage/config-loader
Advanced tools
Comparing version 0.0.0-nightly-20209921112 to 0.0.0-nightly-20210262290
# @backstage/config-loader | ||
## 0.0.0-nightly-20209921112 | ||
## 0.0.0-nightly-20210262290 | ||
### Minor Changes | ||
- 4295a24: Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc. | ||
- 789de4b: Removed support for the deprecated `$data` placeholder. | ||
- 789de4b: Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. | ||
### Patch Changes | ||
- 789de4b: Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `${...}`, which will end up as `${...}`. | ||
For example: | ||
```yaml | ||
app: | ||
baseUrl: https://${BASE_HOST} | ||
``` | ||
## 0.4.1 | ||
### Patch Changes | ||
- ad5c56fd9: 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 | ||
- 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,102 +5,17 @@ 'use strict'; | ||
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } | ||
var yaml2 = require('yaml'); | ||
var path = require('path'); | ||
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 }; } | ||
function isObject(obj) { | ||
if (typeof obj !== "object") { | ||
return false; | ||
} else if (Array.isArray(obj)) { | ||
return false; | ||
} | ||
return obj !== null; | ||
} | ||
var yaml2__default = /*#__PURE__*/_interopDefaultLegacy(yaml2); | ||
var Ajv__default = /*#__PURE__*/_interopDefaultLegacy(Ajv); | ||
var mergeAllOf__default = /*#__PURE__*/_interopDefaultLegacy(mergeAllOf); | ||
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs); | ||
async function readConfigFile(filePath, ctx) { | ||
const configYaml = await ctx.readFile(filePath); | ||
const config2 = yaml2.parse(configYaml); | ||
const context = path.basename(filePath); | ||
async function transform(obj, path2) { | ||
if (ctx.skip(path2)) { | ||
return void 0; | ||
} | ||
if (typeof obj !== "object") { | ||
return obj; | ||
} else if (obj === null) { | ||
return void 0; | ||
} else if (Array.isArray(obj)) { | ||
const arr = new Array(); | ||
for (const [index, value] of obj.entries()) { | ||
const out2 = await transform(value, `${path2}[${index}]`); | ||
if (out2 !== void 0) { | ||
arr.push(out2); | ||
} | ||
} | ||
return arr; | ||
} | ||
if ("$secret" in obj) { | ||
console.warn(`Deprecated secret declaration at '${path2}' in '${context}', use $env, $file, etc. instead`); | ||
if (!isObject(obj.$secret)) { | ||
throw TypeError(`Expected object at secret ${path2}.$secret`); | ||
} | ||
try { | ||
return await ctx.readSecret(path2, obj.$secret); | ||
} catch (error) { | ||
throw new Error(`Invalid secret at ${path2}: ${error.message}`); | ||
} | ||
} | ||
const [secretKey] = Object.keys(obj).filter((key) => key.startsWith("$")); | ||
if (secretKey) { | ||
if (Object.keys(obj).length !== 1) { | ||
throw new Error(`Secret key '${secretKey}' has adjacent keys at ${path2}`); | ||
} | ||
try { | ||
return await ctx.readSecret(path2, { | ||
[secretKey.slice(1)]: obj[secretKey] | ||
}); | ||
} catch (error) { | ||
throw new Error(`Invalid secret at ${path2}: ${error.message}`); | ||
} | ||
} | ||
const out = {}; | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (value !== void 0) { | ||
const result = await transform(value, `${path2}.${key}`); | ||
if (result !== void 0) { | ||
out[key] = result; | ||
} | ||
} | ||
} | ||
return out; | ||
} | ||
const finalConfig = await transform(config2, ""); | ||
if (!isObject(finalConfig)) { | ||
throw new TypeError("Expected object at config root"); | ||
} | ||
return {data: finalConfig, context}; | ||
} | ||
const ENV_PREFIX = "APP_CONFIG_"; | ||
@@ -156,104 +71,434 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; | ||
const secretLoaderSchemas = { | ||
file: yup.object({ | ||
file: yup.string().required() | ||
}), | ||
env: yup.object({ | ||
env: yup.string().required() | ||
}), | ||
data: yup.object({ | ||
data: yup.string().required() | ||
}) | ||
}; | ||
const secretSchema = yup.lazy((value) => { | ||
if (typeof value !== "object" || value === null) { | ||
return yup.object().required().label("secret"); | ||
function isObject(obj) { | ||
if (typeof obj !== "object") { | ||
return false; | ||
} else if (Array.isArray(obj)) { | ||
return false; | ||
} | ||
const loaderTypes = Object.keys(secretLoaderSchemas); | ||
for (const key of loaderTypes) { | ||
if (key in value) { | ||
return secretLoaderSchemas[key]; | ||
return obj !== null; | ||
} | ||
async function applyConfigTransforms(initialDir, input, transforms) { | ||
async function transform(inputObj, path, baseDir) { | ||
var _a; | ||
let obj = inputObj; | ||
let dir = baseDir; | ||
for (const tf of transforms) { | ||
try { | ||
const result = await tf(inputObj, baseDir); | ||
if (result.applied) { | ||
if (result.value === void 0) { | ||
return void 0; | ||
} | ||
obj = result.value; | ||
dir = (_a = result.newBaseDir) != null ? _a : dir; | ||
break; | ||
} | ||
} catch (error) { | ||
throw new Error(`error at ${path}, ${error.message}`); | ||
} | ||
} | ||
if (typeof obj !== "object") { | ||
return obj; | ||
} else if (obj === null) { | ||
return void 0; | ||
} else if (Array.isArray(obj)) { | ||
const arr = new Array(); | ||
for (const [index, value] of obj.entries()) { | ||
const out2 = await transform(value, `${path}[${index}]`, dir); | ||
if (out2 !== void 0) { | ||
arr.push(out2); | ||
} | ||
} | ||
return arr; | ||
} | ||
const out = {}; | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (value !== void 0) { | ||
const result = await transform(value, `${path}.${key}`, dir); | ||
if (result !== void 0) { | ||
out[key] = result; | ||
} | ||
} | ||
} | ||
return out; | ||
} | ||
throw new yup.ValidationError(`Secret must contain one of '${loaderTypes.join("', '")}'`, value, "$secret"); | ||
}); | ||
const dataSecretParser = { | ||
const finalData = await transform(input, "", initialDir); | ||
if (!isObject(finalData)) { | ||
throw new TypeError("expected object at config root"); | ||
} | ||
return finalData; | ||
} | ||
const includeFileParser = { | ||
".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) | ||
}; | ||
async function readSecret(data, ctx) { | ||
const secret = secretSchema.validateSync(data, {strict: true}); | ||
if ("file" in secret) { | ||
return ctx.readFile(secret.file); | ||
} | ||
if ("env" in secret) { | ||
return ctx.env[secret.env]; | ||
} | ||
if ("data" in secret) { | ||
const url = "path" in secret ? `${secret.data}#${secret.path}` : secret.data; | ||
const [filePath, dataPath] = url.split(/#(.*)/); | ||
if (!dataPath) { | ||
throw new Error(`Invalid format for data secret value, must be of the form <filepath>#<datapath>, got '${url}'`); | ||
function createIncludeTransform(env, readFile) { | ||
return async (input, baseDir) => { | ||
if (!isObject(input)) { | ||
return {applied: false}; | ||
} | ||
const ext = path.extname(filePath); | ||
const parser = dataSecretParser[ext]; | ||
if (!parser) { | ||
throw new Error(`No data secret parser available for extension ${ext}`); | ||
const [includeKey] = Object.keys(input).filter((key) => key.startsWith("$")); | ||
if (includeKey) { | ||
if (Object.keys(input).length !== 1) { | ||
throw new Error(`include key ${includeKey} should not have adjacent keys`); | ||
} | ||
} else { | ||
return {applied: false}; | ||
} | ||
const content = await ctx.readFile(filePath); | ||
const parts = dataPath.split("."); | ||
let value = await parser(content); | ||
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}`); | ||
const includeValue = input[includeKey]; | ||
if (typeof includeValue !== "string") { | ||
throw new Error(`${includeKey} include value is not a string`); | ||
} | ||
switch (includeKey) { | ||
case "$file": | ||
try { | ||
const value = await readFile(path.resolve(baseDir, includeValue)); | ||
return {applied: true, value}; | ||
} catch (error) { | ||
throw new Error(`failed to read file ${includeValue}, ${error}`); | ||
} | ||
case "$env": | ||
try { | ||
return {applied: true, value: await env(includeValue)}; | ||
} catch (error) { | ||
throw new Error(`failed to read env ${includeValue}, ${error}`); | ||
} | ||
case "$include": { | ||
const [filePath, dataPath] = includeValue.split(/#(.*)/); | ||
const ext = path.extname(filePath); | ||
const parser = includeFileParser[ext]; | ||
if (!parser) { | ||
throw new Error(`no configuration parser available for included file ${filePath}`); | ||
} | ||
const path2 = path.resolve(baseDir, filePath); | ||
const content = await readFile(path2); | ||
const newBaseDir = path.dirname(path2); | ||
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 at '${errPath}' in included file ${filePath} is not an object`); | ||
} | ||
value = value[part]; | ||
} | ||
return { | ||
applied: true, | ||
value, | ||
newBaseDir: newBaseDir !== baseDir ? newBaseDir : void 0 | ||
}; | ||
} | ||
value = value[part]; | ||
default: | ||
throw new Error(`unknown include ${includeKey}`); | ||
} | ||
return String(value); | ||
} | ||
throw new Error("Secret was left unhandled"); | ||
}; | ||
} | ||
class Context { | ||
constructor(options) { | ||
this.options = options; | ||
function createSubstitutionTransform(env) { | ||
return async (input) => { | ||
if (typeof input !== "string") { | ||
return {applied: false}; | ||
} | ||
const parts = input.split(/(?<!\$)\$\{([^{}]+)\}/); | ||
for (let i = 1; i < parts.length; i += 2) { | ||
parts[i] = await env(parts[i].trim()); | ||
} | ||
if (parts.some((part) => part === void 0)) { | ||
return {applied: true, value: void 0}; | ||
} | ||
return {applied: true, value: parts.join("")}; | ||
}; | ||
} | ||
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}))); | ||
} | ||
get env() { | ||
return this.options.env; | ||
await Promise.all(packageNames.map((name) => processItem({name}))); | ||
const tsSchemas = compileTsSchemas(tsSchemaPaths); | ||
return schemas.concat(tsSchemas); | ||
} | ||
function compileTsSchemas(paths) { | ||
if (paths.length === 0) { | ||
return []; | ||
} | ||
skip(path2) { | ||
if (this.options.shouldReadSecrets) { | ||
return false; | ||
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; | ||
} | ||
} | ||
return this.options.secretPaths.has(path2); | ||
} | ||
async readFile(path2) { | ||
return fs__default.readFile(path.resolve(this.options.rootPath, path2), "utf8"); | ||
} | ||
async readSecret(path2, desc) { | ||
this.options.secretPaths.add(path2); | ||
if (!this.options.shouldReadSecrets) { | ||
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; | ||
} | ||
return readSecret(desc, this); | ||
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 | ||
}; | ||
} | ||
}; | ||
} | ||
async function loadConfig(options) { | ||
const configs = []; | ||
const configPaths = await resolveStaticConfig(options); | ||
const {configRoot, experimentalEnvFunc: envFunc} = 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 env = envFunc != null ? envFunc : async (name) => process.env[name]; | ||
try { | ||
const secretPaths = new Set(); | ||
for (const configPath of configPaths) { | ||
const config2 = await readConfigFile(configPath, new Context({ | ||
secretPaths, | ||
env: process.env, | ||
rootPath: path.dirname(configPath), | ||
shouldReadSecrets: Boolean(options.shouldReadSecrets) | ||
})); | ||
configs.push(config2); | ||
if (!path.isAbsolute(configPath)) { | ||
throw new Error(`Config load path is not absolute: '${configPath}'`); | ||
} | ||
const dir = path.dirname(configPath); | ||
const readFile = (path2) => fs__default['default'].readFile(path.resolve(dir, path2), "utf8"); | ||
const input = yaml2__default['default'].parse(await readFile(configPath)); | ||
const data = await applyConfigTransforms(dir, input, [ | ||
createIncludeTransform(env, readFile), | ||
createSubstitutionTransform(env) | ||
]); | ||
configs.push({data, context: path.basename(configPath)}); | ||
} | ||
} catch (error) { | ||
throw new Error(`Failed to read static configuration file: ${error.message}`); | ||
throw new Error(`Failed to read static configuration file, ${error.message}`); | ||
} | ||
@@ -265,3 +510,4 @@ configs.push(...readEnvConfig(process.env)); | ||
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,67 @@ /** | ||
declare type EnvFunc = (name: string) => Promise<string | undefined>; | ||
/** | ||
* 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[]; | ||
env: string; | ||
shouldReadSecrets?: boolean; | ||
configRoot: string; | ||
configPaths: string[]; | ||
/** @deprecated This option has been removed */ | ||
env?: string; | ||
/** | ||
* Custom environment variable loading function | ||
* | ||
* @experimental This API is not stable and may change at any point | ||
*/ | ||
experimentalEnvFunc?: EnvFunc; | ||
}; | ||
declare function loadConfig(options: LoadConfigOptions): Promise<AppConfig[]>; | ||
export { LoadConfigOptions, loadConfig, readEnvConfig }; | ||
export { ConfigSchema, ConfigVisibility, LoadConfigOptions, loadConfig, loadConfigSchema, readEnvConfig }; |
@@ -1,98 +0,9 @@ | ||
import { resolve, basename, extname, dirname } from 'path'; | ||
import fs, { pathExists } from 'fs-extra'; | ||
import yaml2 from 'yaml'; | ||
import { object, string, lazy, ValidationError } from 'yup'; | ||
import { extname, resolve, dirname, sep, relative, isAbsolute, basename } from 'path'; | ||
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) { | ||
if (typeof obj !== "object") { | ||
return false; | ||
} else if (Array.isArray(obj)) { | ||
return false; | ||
} | ||
return obj !== null; | ||
} | ||
async function readConfigFile(filePath, ctx) { | ||
const configYaml = await ctx.readFile(filePath); | ||
const config2 = yaml2.parse(configYaml); | ||
const context = basename(filePath); | ||
async function transform(obj, path2) { | ||
if (ctx.skip(path2)) { | ||
return void 0; | ||
} | ||
if (typeof obj !== "object") { | ||
return obj; | ||
} else if (obj === null) { | ||
return void 0; | ||
} else if (Array.isArray(obj)) { | ||
const arr = new Array(); | ||
for (const [index, value] of obj.entries()) { | ||
const out2 = await transform(value, `${path2}[${index}]`); | ||
if (out2 !== void 0) { | ||
arr.push(out2); | ||
} | ||
} | ||
return arr; | ||
} | ||
if ("$secret" in obj) { | ||
console.warn(`Deprecated secret declaration at '${path2}' in '${context}', use $env, $file, etc. instead`); | ||
if (!isObject(obj.$secret)) { | ||
throw TypeError(`Expected object at secret ${path2}.$secret`); | ||
} | ||
try { | ||
return await ctx.readSecret(path2, obj.$secret); | ||
} catch (error) { | ||
throw new Error(`Invalid secret at ${path2}: ${error.message}`); | ||
} | ||
} | ||
const [secretKey] = Object.keys(obj).filter((key) => key.startsWith("$")); | ||
if (secretKey) { | ||
if (Object.keys(obj).length !== 1) { | ||
throw new Error(`Secret key '${secretKey}' has adjacent keys at ${path2}`); | ||
} | ||
try { | ||
return await ctx.readSecret(path2, { | ||
[secretKey.slice(1)]: obj[secretKey] | ||
}); | ||
} catch (error) { | ||
throw new Error(`Invalid secret at ${path2}: ${error.message}`); | ||
} | ||
} | ||
const out = {}; | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (value !== void 0) { | ||
const result = await transform(value, `${path2}.${key}`); | ||
if (result !== void 0) { | ||
out[key] = result; | ||
} | ||
} | ||
} | ||
return out; | ||
} | ||
const finalConfig = await transform(config2, ""); | ||
if (!isObject(finalConfig)) { | ||
throw new TypeError("Expected object at config root"); | ||
} | ||
return {data: finalConfig, context}; | ||
} | ||
const ENV_PREFIX = "APP_CONFIG_"; | ||
@@ -148,26 +59,64 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; | ||
const secretLoaderSchemas = { | ||
file: object({ | ||
file: string().required() | ||
}), | ||
env: object({ | ||
env: string().required() | ||
}), | ||
data: object({ | ||
data: string().required() | ||
}) | ||
}; | ||
const secretSchema = lazy((value) => { | ||
if (typeof value !== "object" || value === null) { | ||
return object().required().label("secret"); | ||
function isObject(obj) { | ||
if (typeof obj !== "object") { | ||
return false; | ||
} else if (Array.isArray(obj)) { | ||
return false; | ||
} | ||
const loaderTypes = Object.keys(secretLoaderSchemas); | ||
for (const key of loaderTypes) { | ||
if (key in value) { | ||
return secretLoaderSchemas[key]; | ||
return obj !== null; | ||
} | ||
async function applyConfigTransforms(initialDir, input, transforms) { | ||
async function transform(inputObj, path, baseDir) { | ||
var _a; | ||
let obj = inputObj; | ||
let dir = baseDir; | ||
for (const tf of transforms) { | ||
try { | ||
const result = await tf(inputObj, baseDir); | ||
if (result.applied) { | ||
if (result.value === void 0) { | ||
return void 0; | ||
} | ||
obj = result.value; | ||
dir = (_a = result.newBaseDir) != null ? _a : dir; | ||
break; | ||
} | ||
} catch (error) { | ||
throw new Error(`error at ${path}, ${error.message}`); | ||
} | ||
} | ||
if (typeof obj !== "object") { | ||
return obj; | ||
} else if (obj === null) { | ||
return void 0; | ||
} else if (Array.isArray(obj)) { | ||
const arr = new Array(); | ||
for (const [index, value] of obj.entries()) { | ||
const out2 = await transform(value, `${path}[${index}]`, dir); | ||
if (out2 !== void 0) { | ||
arr.push(out2); | ||
} | ||
} | ||
return arr; | ||
} | ||
const out = {}; | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (value !== void 0) { | ||
const result = await transform(value, `${path}.${key}`, dir); | ||
if (result !== void 0) { | ||
out[key] = result; | ||
} | ||
} | ||
} | ||
return out; | ||
} | ||
throw new ValidationError(`Secret must contain one of '${loaderTypes.join("', '")}'`, value, "$secret"); | ||
}); | ||
const dataSecretParser = { | ||
const finalData = await transform(input, "", initialDir); | ||
if (!isObject(finalData)) { | ||
throw new TypeError("expected object at config root"); | ||
} | ||
return finalData; | ||
} | ||
const includeFileParser = { | ||
".json": async (content) => JSON.parse(content), | ||
@@ -177,76 +126,368 @@ ".yaml": async (content) => yaml2.parse(content), | ||
}; | ||
async function readSecret(data, ctx) { | ||
const secret = secretSchema.validateSync(data, {strict: true}); | ||
if ("file" in secret) { | ||
return ctx.readFile(secret.file); | ||
} | ||
if ("env" in secret) { | ||
return ctx.env[secret.env]; | ||
} | ||
if ("data" in secret) { | ||
const url = "path" in secret ? `${secret.data}#${secret.path}` : secret.data; | ||
const [filePath, dataPath] = url.split(/#(.*)/); | ||
if (!dataPath) { | ||
throw new Error(`Invalid format for data secret value, must be of the form <filepath>#<datapath>, got '${url}'`); | ||
function createIncludeTransform(env, readFile) { | ||
return async (input, baseDir) => { | ||
if (!isObject(input)) { | ||
return {applied: false}; | ||
} | ||
const ext = extname(filePath); | ||
const parser = dataSecretParser[ext]; | ||
if (!parser) { | ||
throw new Error(`No data secret parser available for extension ${ext}`); | ||
const [includeKey] = Object.keys(input).filter((key) => key.startsWith("$")); | ||
if (includeKey) { | ||
if (Object.keys(input).length !== 1) { | ||
throw new Error(`include key ${includeKey} should not have adjacent keys`); | ||
} | ||
} else { | ||
return {applied: false}; | ||
} | ||
const content = await ctx.readFile(filePath); | ||
const parts = dataPath.split("."); | ||
let value = await parser(content); | ||
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}`); | ||
const includeValue = input[includeKey]; | ||
if (typeof includeValue !== "string") { | ||
throw new Error(`${includeKey} include value is not a string`); | ||
} | ||
switch (includeKey) { | ||
case "$file": | ||
try { | ||
const value = await readFile(resolve(baseDir, includeValue)); | ||
return {applied: true, value}; | ||
} catch (error) { | ||
throw new Error(`failed to read file ${includeValue}, ${error}`); | ||
} | ||
case "$env": | ||
try { | ||
return {applied: true, value: await env(includeValue)}; | ||
} catch (error) { | ||
throw new Error(`failed to read env ${includeValue}, ${error}`); | ||
} | ||
case "$include": { | ||
const [filePath, dataPath] = includeValue.split(/#(.*)/); | ||
const ext = extname(filePath); | ||
const parser = includeFileParser[ext]; | ||
if (!parser) { | ||
throw new Error(`no configuration parser available for included file ${filePath}`); | ||
} | ||
const path2 = resolve(baseDir, filePath); | ||
const content = await readFile(path2); | ||
const newBaseDir = dirname(path2); | ||
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 at '${errPath}' in included file ${filePath} is not an object`); | ||
} | ||
value = value[part]; | ||
} | ||
return { | ||
applied: true, | ||
value, | ||
newBaseDir: newBaseDir !== baseDir ? newBaseDir : void 0 | ||
}; | ||
} | ||
value = value[part]; | ||
default: | ||
throw new Error(`unknown include ${includeKey}`); | ||
} | ||
return String(value); | ||
} | ||
throw new Error("Secret was left unhandled"); | ||
}; | ||
} | ||
class Context { | ||
constructor(options) { | ||
this.options = options; | ||
function createSubstitutionTransform(env) { | ||
return async (input) => { | ||
if (typeof input !== "string") { | ||
return {applied: false}; | ||
} | ||
const parts = input.split(/(?<!\$)\$\{([^{}]+)\}/); | ||
for (let i = 1; i < parts.length; i += 2) { | ||
parts[i] = await env(parts[i].trim()); | ||
} | ||
if (parts.some((part) => part === void 0)) { | ||
return {applied: true, value: void 0}; | ||
} | ||
return {applied: true, value: parts.join("")}; | ||
}; | ||
} | ||
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}))); | ||
} | ||
get env() { | ||
return this.options.env; | ||
await Promise.all(packageNames.map((name) => processItem({name}))); | ||
const tsSchemas = compileTsSchemas(tsSchemaPaths); | ||
return schemas.concat(tsSchemas); | ||
} | ||
function compileTsSchemas(paths) { | ||
if (paths.length === 0) { | ||
return []; | ||
} | ||
skip(path2) { | ||
if (this.options.shouldReadSecrets) { | ||
return false; | ||
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; | ||
} | ||
} | ||
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) { | ||
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; | ||
} | ||
return readSecret(desc, this); | ||
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 | ||
}; | ||
} | ||
}; | ||
} | ||
async function loadConfig(options) { | ||
const configs = []; | ||
const configPaths = await resolveStaticConfig(options); | ||
const {configRoot, experimentalEnvFunc: envFunc} = 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 env = envFunc != null ? envFunc : async (name) => process.env[name]; | ||
try { | ||
const secretPaths = new Set(); | ||
for (const configPath of configPaths) { | ||
const config2 = await readConfigFile(configPath, new Context({ | ||
secretPaths, | ||
env: process.env, | ||
rootPath: dirname(configPath), | ||
shouldReadSecrets: Boolean(options.shouldReadSecrets) | ||
})); | ||
configs.push(config2); | ||
if (!isAbsolute(configPath)) { | ||
throw new Error(`Config load path is not absolute: '${configPath}'`); | ||
} | ||
const dir = dirname(configPath); | ||
const readFile = (path2) => fs.readFile(resolve(dir, path2), "utf8"); | ||
const input = yaml2.parse(await readFile(configPath)); | ||
const data = await applyConfigTransforms(dir, input, [ | ||
createIncludeTransform(env, readFile), | ||
createSubstitutionTransform(env) | ||
]); | ||
configs.push({data, context: basename(configPath)}); | ||
} | ||
} catch (error) { | ||
throw new Error(`Failed to read static configuration file: ${error.message}`); | ||
throw new Error(`Failed to read static configuration file, ${error.message}`); | ||
} | ||
@@ -257,3 +498,3 @@ configs.push(...readEnvConfig(process.env)); | ||
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-20210262290", | ||
"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.47.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
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
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
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
126713
1063
9
7
8
1
30
+ 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.1.1(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.7(transitive)
+ Addedget-proto@1.0.1(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.1(transitive)
+ Addedjsonify@0.0.1(transitive)
+ Addedmath-intrinsics@1.1.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.47.0(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