diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 7018dc40a8..de6b26363a 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -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}" diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 822fb53d04..6738611a17 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -15,4 +15,4 @@ */ export { loadConfig } from './loader'; -export type { LoadConfigOptions } from './types'; +export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/lib/env.test.ts similarity index 98% rename from packages/config-loader/src/loader.test.ts rename to packages/config-loader/src/lib/env.test.ts index 4c7a120c63..6a0a115b24 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/lib/env.test.ts @@ -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', () => { diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts new file mode 100644 index 0000000000..83f86d4212 --- /dev/null +++ b/packages/config-loader/src/lib/env.ts @@ -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] : []; +} diff --git a/packages/config-loader/src/types.ts b/packages/config-loader/src/lib/index.ts similarity index 78% rename from packages/config-loader/src/types.ts rename to packages/config-loader/src/lib/index.ts index d25a2d3e8b..226932784b 100644 --- a/packages/config-loader/src/types.ts +++ b/packages/config-loader/src/lib/index.ts @@ -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'; diff --git a/packages/config-loader/src/paths.test.ts b/packages/config-loader/src/lib/paths.test.ts similarity index 100% rename from packages/config-loader/src/paths.test.ts rename to packages/config-loader/src/lib/paths.test.ts diff --git a/packages/config-loader/src/paths.ts b/packages/config-loader/src/lib/paths.ts similarity index 100% rename from packages/config-loader/src/paths.ts rename to packages/config-loader/src/lib/paths.ts diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts new file mode 100644 index 0000000000..b90c53ee6b --- /dev/null +++ b/packages/config-loader/src/lib/reader.test.ts @@ -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'); + }); +}); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts new file mode 100644 index 0000000000..044bf68288 --- /dev/null +++ b/packages/config-loader/src/lib/reader.ts @@ -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 { + if (typeof obj !== 'object') { + return obj; + } else if (obj === null) { + return undefined; + } else if (Array.isArray(obj)) { + const arr = new Array(); + + 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; +} diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts new file mode 100644 index 0000000000..4a06f3ebca --- /dev/null +++ b/packages/config-loader/src/lib/resolver.ts @@ -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 { + // 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]; +} diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts new file mode 100644 index 0000000000..e7fe997a58 --- /dev/null +++ b/packages/config-loader/src/lib/secrets.test.ts @@ -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', + ); + }); +}); diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts new file mode 100644 index 0000000000..c4157120ae --- /dev/null +++ b/packages/config-loader/src/lib/secrets.ts @@ -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(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; +} = { + '.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 { + 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(); + throw new Error('Secret was left unhandled'); +} diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts new file mode 100644 index 0000000000..c0f8b99dcf --- /dev/null +++ b/packages/config-loader/src/lib/types.ts @@ -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; +export type ReadSecretFunc = (desc: JsonObject) => Promise; + +/** + * Common context that provides all the necessary hooks for reading configuration files. + */ +export type ReaderContext = { + env: { [name in string]?: string }; + readFile: ReadFileFunc; + readSecret: ReadSecretFunc; +}; diff --git a/packages/config-loader/src/lib/utils.ts b/packages/config-loader/src/lib/utils.ts new file mode 100644 index 0000000000..37145ec971 --- /dev/null +++ b/packages/config-loader/src/lib/utils.ts @@ -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() { + return void 0 as T; +} diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index c8b5bb538d..1b6c8f34da 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -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 { - // 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 { + 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 { + 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; }