Merge pull request #1277 from spotify/rugvip/secrets
packages/config-loader: implement secrets parsing + tests and refactoring
This commit is contained in:
@@ -32,11 +32,13 @@
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"fs-extra": "^9.0.0",
|
||||
"yaml": "^1.9.2"
|
||||
"yaml": "^1.9.2",
|
||||
"yup": "^0.28.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0"
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/yup": "^0.28.2"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { loadConfig } from './loader';
|
||||
export type { LoadConfigOptions } from './types';
|
||||
export type { LoadConfigOptions } from './loader';
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { readEnv } from './loader';
|
||||
import { readEnv } from './env';
|
||||
|
||||
describe('readEnv', () => {
|
||||
it('should return empty config for empty env', () => {
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { AppConfig, JsonObject } from '@backstage/config';
|
||||
|
||||
const ENV_PREFIX = 'APP_CONFIG_';
|
||||
|
||||
// Update the same pattern in config package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
|
||||
/**
|
||||
* Read runtime configuration from the environment.
|
||||
*
|
||||
* Only environment variables prefixed with APP_CONFIG_ will be considered.
|
||||
*
|
||||
* For each variable, the prefix will be removed, and rest of the key will
|
||||
* be split by '_'. Each part will then be used as keys to build up a nested
|
||||
* config object structure. The treatment of the entire environment variable
|
||||
* is case-sensitive.
|
||||
*
|
||||
* The value of the variable should be JSON serialized, as it will be parsed
|
||||
* and the type will be kept intact. For example "true" and true are treated
|
||||
* differently, as well as "42" and 42.
|
||||
*
|
||||
* For example, to set the config app.title to "My Title", use the following:
|
||||
*
|
||||
* APP_CONFIG_app_title='"My Title"'
|
||||
*/
|
||||
export function readEnv(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
let config: JsonObject | undefined = undefined;
|
||||
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith(ENV_PREFIX)) {
|
||||
const key = name.replace(ENV_PREFIX, '');
|
||||
const keyParts = key.split('_');
|
||||
|
||||
let obj = (config = config ?? {});
|
||||
for (const [index, part] of keyParts.entries()) {
|
||||
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
|
||||
throw new TypeError(`Invalid env config key '${key}'`);
|
||||
}
|
||||
if (index < keyParts.length - 1) {
|
||||
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
const subKey = keyParts.slice(0, index + 1).join('_');
|
||||
throw new TypeError(
|
||||
`Could not nest config for key '${key}' under existing value '${subKey}'`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (part in obj) {
|
||||
throw new TypeError(
|
||||
`Refusing to override existing config at key '${key}'`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (parsedValue === null) {
|
||||
throw new Error('value may not be null');
|
||||
}
|
||||
obj[part] = parsedValue;
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config ? [config] : [];
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type LoadConfigOptions = {
|
||||
// Config path, defaults to app-config.yaml in project root
|
||||
configPath?: string;
|
||||
};
|
||||
export { resolveStaticConfig } from './resolver';
|
||||
export { readConfigFile } from './reader';
|
||||
export { readEnv } from './env';
|
||||
export { readSecret } from './secrets';
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { readConfigFile } from './reader';
|
||||
import { ReaderContext, ReadSecretFunc } from './types';
|
||||
|
||||
function memoryFiles(files: { [path: string]: string }) {
|
||||
return async (path: string) => {
|
||||
if (path in files) {
|
||||
return files[path];
|
||||
}
|
||||
throw new Error(`File not found, ${path}`);
|
||||
};
|
||||
}
|
||||
|
||||
describe('readConfigFile', () => {
|
||||
it('should read a plain config file', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml':
|
||||
'app: { title: "Test", x: 1, y: [null, true], z: null }',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).resolves.toEqual({
|
||||
app: {
|
||||
title: 'Test',
|
||||
x: 1,
|
||||
y: [true],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should error out if the config file has invalid syntax', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { title: ]',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
|
||||
});
|
||||
|
||||
it('should error out if config is not an object', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': '[]',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).rejects.toThrow('Expected object at config root');
|
||||
});
|
||||
|
||||
it('should read secrets', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
|
||||
});
|
||||
const readSecret = jest.fn().mockResolvedValue('secret');
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
await expect(config).resolves.toEqual({
|
||||
app: 'secret',
|
||||
});
|
||||
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
|
||||
});
|
||||
|
||||
it('should require secrets to be objects', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
|
||||
});
|
||||
const readSecret = jest.fn().mockResolvedValue('secret');
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
expect(readSecret).not.toHaveBeenCalled();
|
||||
await expect(config).rejects.toThrow(
|
||||
'Expected object at secret .app.$secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward secret reading errors', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: {} }',
|
||||
});
|
||||
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 yaml from 'yaml';
|
||||
import { isObject } from './utils';
|
||||
import { JsonValue, JsonObject } from '@backstage/config';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
/**
|
||||
* Reads and parses, and validates, and transforms a single config file.
|
||||
* The transformation rewrites any special values, like the $secret key.
|
||||
*/
|
||||
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
|
||||
const configYaml = await ctx.readFile(filePath);
|
||||
const config = yaml.parse(configYaml);
|
||||
|
||||
async function transform(
|
||||
obj: JsonValue,
|
||||
path: string,
|
||||
): Promise<JsonValue | undefined> {
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
} else if (obj === null) {
|
||||
return undefined;
|
||||
} else if (Array.isArray(obj)) {
|
||||
const arr = new Array<JsonValue>();
|
||||
|
||||
for (const [index, value] of obj.entries()) {
|
||||
const out = await transform(value, `${path}[${index}]`);
|
||||
if (out !== undefined) {
|
||||
arr.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
if ('$secret' in obj) {
|
||||
if (!isObject(obj.$secret)) {
|
||||
throw TypeError(`Expected object at secret ${path}.$secret`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await ctx.readSecret(obj.$secret);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid secret at ${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const out: JsonObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const result = await transform(value, `${path}.${key}`);
|
||||
if (result !== undefined) {
|
||||
out[key] = result;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const finalConfig = await transform(config, '');
|
||||
if (!isObject(finalConfig)) {
|
||||
throw new TypeError('Expected object at config root');
|
||||
}
|
||||
return finalConfig;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 } from 'path';
|
||||
import { findRootPath } from './paths';
|
||||
|
||||
type ResolveOptions = {
|
||||
// Same as configPath in LoadConfigOptions
|
||||
configPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves all configuration files that should be loaded in the given environment.
|
||||
*/
|
||||
export async function resolveStaticConfig(
|
||||
options: ResolveOptions,
|
||||
): Promise<string[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
let { configPath } = options;
|
||||
if (!configPath) {
|
||||
configPath = resolvePath(
|
||||
findRootPath(fs.realpathSync(process.cwd())),
|
||||
'app-config.yaml',
|
||||
);
|
||||
}
|
||||
|
||||
return [configPath];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 { readSecret } from './secrets';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
const ctx: ReaderContext = {
|
||||
env: {
|
||||
SECRET: 'my-secret',
|
||||
},
|
||||
readSecret: jest.fn(),
|
||||
async readFile(path) {
|
||||
const content = ({
|
||||
'my-secret': 'secret',
|
||||
'my-data.json': '{"a":{"b":{"c":42}}}',
|
||||
'my-data.yaml': 'some:\n yaml:\n key: 7',
|
||||
'my-data.yml': 'different: { key: hello }',
|
||||
} as { [key: string]: string })[path];
|
||||
|
||||
if (!content) {
|
||||
throw new Error('File not found!');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
};
|
||||
|
||||
describe('readSecret', () => {
|
||||
it('should read file secrets', async () => {
|
||||
await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
|
||||
'secret',
|
||||
);
|
||||
await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
|
||||
'File not found!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should read present env secrets', async () => {
|
||||
await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
|
||||
await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should read data secrets', async () => {
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
|
||||
).resolves.toBe('42');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
|
||||
).resolves.toBe('7');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
|
||||
).resolves.toBe('hello');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
|
||||
).rejects.toThrow('File not found!');
|
||||
});
|
||||
|
||||
it('should reject invalid secrets', async () => {
|
||||
await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
|
||||
'secret must be a `object` type, but the final value was: `"hello"`.',
|
||||
);
|
||||
await expect(readSecret({}, ctx)).rejects.toThrow(
|
||||
"Secret must contain one of 'file', 'env', 'data'",
|
||||
);
|
||||
await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
|
||||
"Secret must contain one of 'file', 'env', 'data'",
|
||||
);
|
||||
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
|
||||
'path is a required field',
|
||||
);
|
||||
await expect(
|
||||
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
|
||||
).rejects.toThrow('No data secret parser available for extension .js');
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
|
||||
).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
|
||||
});
|
||||
|
||||
it('should have 100% test coverage', async () => {
|
||||
let firstVisit = true;
|
||||
const secret = {};
|
||||
const proto = {
|
||||
get file() {
|
||||
if (!firstVisit) {
|
||||
Object.setPrototypeOf(secret, {});
|
||||
}
|
||||
firstVisit = false;
|
||||
return 'a-file';
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(secret, proto);
|
||||
|
||||
await expect(readSecret(secret, ctx)).rejects.toThrow(
|
||||
'Secret was left unhandled',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 * as yup from 'yup';
|
||||
import yaml from 'yaml';
|
||||
import { extname } from 'path';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { isObject, isNever } from './utils';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
// Reads a file and forwards the contents as is, assuming ut8 encoding
|
||||
type FileSecret = {
|
||||
// Path to the secret file, relative to the config file.
|
||||
file: string;
|
||||
};
|
||||
|
||||
// Reads the secret from an environment variable.
|
||||
type EnvSecret = {
|
||||
// The name of the environment file.
|
||||
env: string;
|
||||
};
|
||||
|
||||
// Reads a secret from a json-like file and extracts a value at a path.
|
||||
// The supported extensions are define in dataSecretParser below.
|
||||
type DataSecret = {
|
||||
// Path to the data secret file, relative to the config file.
|
||||
data: string;
|
||||
// The path to the value inside the data file.
|
||||
// Either a '.' separated list, or an array of path segments.
|
||||
path: string | string[];
|
||||
};
|
||||
|
||||
type Secret = FileSecret | EnvSecret | DataSecret;
|
||||
|
||||
// Schema for each type of secret description
|
||||
const secretLoaderSchemas = {
|
||||
file: yup.object({
|
||||
file: yup.string().required(),
|
||||
}),
|
||||
env: yup.object({
|
||||
env: yup.string().required(),
|
||||
}),
|
||||
data: yup.object({
|
||||
data: yup.string().required(),
|
||||
path: yup.lazy(value => {
|
||||
if (typeof value === 'string') {
|
||||
return yup.string().required();
|
||||
}
|
||||
return yup.array().of(yup.string().required()).required();
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// The top-level secret schema, which figures out what type of secret it is.
|
||||
const secretSchema = yup.lazy<object>(value => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return yup.object().required().label('secret');
|
||||
}
|
||||
|
||||
const loaderTypes = Object.keys(
|
||||
secretLoaderSchemas,
|
||||
) as (keyof typeof secretLoaderSchemas)[];
|
||||
|
||||
for (const key of loaderTypes) {
|
||||
if (key in value) {
|
||||
return secretLoaderSchemas[key];
|
||||
}
|
||||
}
|
||||
throw new yup.ValidationError(
|
||||
`Secret must contain one of '${loaderTypes.join("', '")}'`,
|
||||
value,
|
||||
'$secret',
|
||||
);
|
||||
});
|
||||
|
||||
// Parsers for each type of data secret file.
|
||||
const dataSecretParser: {
|
||||
[ext in string]: (content: string) => Promise<JsonObject>;
|
||||
} = {
|
||||
'.json': async content => JSON.parse(content),
|
||||
'.yaml': async content => yaml.parse(content),
|
||||
'.yml': async content => yaml.parse(content),
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a secret description into the actual secret value.
|
||||
*/
|
||||
export async function readSecret(
|
||||
data: JsonObject,
|
||||
ctx: ReaderContext,
|
||||
): Promise<string | undefined> {
|
||||
const secret = secretSchema.validateSync(data, { strict: true }) as Secret;
|
||||
|
||||
if ('file' in secret) {
|
||||
return ctx.readFile(secret.file);
|
||||
}
|
||||
if ('env' in secret) {
|
||||
return ctx.env[secret.env];
|
||||
}
|
||||
if ('data' in secret) {
|
||||
const ext = extname(secret.data);
|
||||
const parser = dataSecretParser[ext];
|
||||
if (!parser) {
|
||||
throw new Error(`No data secret parser available for extension ${ext}`);
|
||||
}
|
||||
|
||||
const content = await ctx.readFile(secret.data);
|
||||
|
||||
const { path } = secret;
|
||||
const parts = typeof path === 'string' ? path.split('.') : path;
|
||||
|
||||
let value: JsonValue = 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 ${secret.data}`,
|
||||
);
|
||||
}
|
||||
value = value[part];
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
isNever<typeof secret>();
|
||||
throw new Error('Secret was left unhandled');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { JsonObject } from '@backstage/config';
|
||||
|
||||
export type ReadFileFunc = (path: string) => Promise<string>;
|
||||
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Common context that provides all the necessary hooks for reading configuration files.
|
||||
*/
|
||||
export type ReaderContext = {
|
||||
env: { [name in string]?: string };
|
||||
readFile: ReadFileFunc;
|
||||
readSecret: ReadSecretFunc;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { JsonValue, JsonObject } from '@backstage/config';
|
||||
|
||||
export function isObject(obj: JsonValue | undefined): obj is JsonObject {
|
||||
if (typeof obj !== 'object') {
|
||||
return false;
|
||||
} else if (Array.isArray(obj)) {
|
||||
return false;
|
||||
}
|
||||
return obj !== null;
|
||||
}
|
||||
|
||||
// A thing to make sure we've narrowed the type down to never
|
||||
export function isNever<T extends never>() {
|
||||
return void 0 as T;
|
||||
}
|
||||
@@ -15,87 +15,46 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { findRootPath } from './paths';
|
||||
import { LoadConfigOptions } from './types';
|
||||
import {
|
||||
resolveStaticConfig,
|
||||
readConfigFile,
|
||||
readEnv,
|
||||
readSecret,
|
||||
} from './lib';
|
||||
|
||||
const ENV_PREFIX = 'APP_CONFIG_';
|
||||
export type LoadConfigOptions = {
|
||||
// Config path, defaults to app-config.yaml in project root
|
||||
configPath?: string;
|
||||
|
||||
// Update the same pattern in config package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
// Whether to read secrets or omit them, defaults to false.
|
||||
shouldReadSecrets?: boolean;
|
||||
};
|
||||
|
||||
export function readEnv(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
let config: JsonObject | undefined = undefined;
|
||||
class Context {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
env: { [name in string]?: string };
|
||||
rootPath: string;
|
||||
shouldReadSecrets: boolean;
|
||||
},
|
||||
) {}
|
||||
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith(ENV_PREFIX)) {
|
||||
const key = name.replace(ENV_PREFIX, '');
|
||||
const keyParts = key.split('_');
|
||||
|
||||
let obj = (config = config ?? {});
|
||||
for (const [index, part] of keyParts.entries()) {
|
||||
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
|
||||
throw new TypeError(`Invalid env config key '${key}'`);
|
||||
}
|
||||
if (index < keyParts.length - 1) {
|
||||
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
const subKey = keyParts.slice(0, index + 1).join('_');
|
||||
throw new TypeError(
|
||||
`Could not nest config for key '${key}' under existing value '${subKey}'`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (part in obj) {
|
||||
throw new TypeError(
|
||||
`Refusing to override existing config at key '${key}'`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (parsedValue === null) {
|
||||
throw new Error('value may not be null');
|
||||
}
|
||||
obj[part] = parsedValue;
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
get env() {
|
||||
return this.options.env;
|
||||
}
|
||||
|
||||
return config ? [config] : [];
|
||||
}
|
||||
|
||||
export async function readStaticConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<AppConfig[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
let { configPath } = options;
|
||||
if (!configPath) {
|
||||
configPath = resolvePath(
|
||||
findRootPath(fs.realpathSync(process.cwd())),
|
||||
'app-config.yaml',
|
||||
);
|
||||
async readFile(path: string): Promise<string> {
|
||||
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
|
||||
}
|
||||
|
||||
try {
|
||||
const configYaml = await fs.readFile(configPath, 'utf8');
|
||||
const config = yaml.parse(configYaml);
|
||||
return [config];
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read static configuration file, ${error}`);
|
||||
async readSecret(desc: JsonObject): Promise<string | undefined> {
|
||||
if (!this.options.shouldReadSecrets) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return readSecret(desc, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +64,27 @@ export async function loadConfig(
|
||||
const configs = [];
|
||||
|
||||
configs.push(...readEnv(process.env));
|
||||
configs.push(...(await readStaticConfig(options)));
|
||||
|
||||
const configPaths = await resolveStaticConfig(options);
|
||||
|
||||
try {
|
||||
for (const configPath of configPaths) {
|
||||
const config = await readConfigFile(
|
||||
configPath,
|
||||
new Context({
|
||||
env: process.env,
|
||||
rootPath: dirname(configPath),
|
||||
shouldReadSecrets: Boolean(options.shouldReadSecrets),
|
||||
}),
|
||||
);
|
||||
|
||||
configs.push(config);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read static configuration file: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user