diff --git a/Dockerfile b/Dockerfile index 86595ea5a2..fa89debcee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ FROM nginx:mainline # The safest way to build this image is to use `yarn docker-build` +RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* + COPY packages/app/dist /usr/share/nginx/html COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template COPY docker/run.sh /usr/local/bin/run.sh diff --git a/docker/run.sh b/docker/run.sh index 7bc5f5fa39..ac120f6597 100755 --- a/docker/run.sh +++ b/docker/run.sh @@ -1,9 +1,41 @@ #!/usr/bin/env bash +set -Eeuo pipefail + # Run nginx as root sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf # Write selected env vars to nginx config envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf +# Inject runtime config into the client +function inject_config() { + # Read runtime config from env in the same way as the @backstage/config-loader package + local config + config="$(jq -n 'env | + with_entries(select(.key | startswith("APP_CONFIG_")) | .key |= sub("APP_CONFIG_"; "")) | + to_entries | + reduce .[] as $item ( + {}; setpath($item.key | split("_"); $item.value | fromjson) + )')" + + >&2 echo "Runtime app config: $config" + + local main_js + main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)" + echo "Writing runtime config to ${main_js}" + + # escape ' and " twice, for both sed and json + local config_escaped_1 + config_escaped_1="$(echo "$config" | jq -cM . | sed -e 's/[\\"\x27]/\\&/g')" # \x27 = ' + # escape / and & for sed + local config_escaped_2 + config_escaped_2="$(echo "$config_escaped_1" | sed -e 's/[\/&]/\\&/g')" + + # Replace __APP_INJECTED_RUNTIME_CONFIG__ in the main chunk with the runtime config + sed -e "s/__APP_INJECTED_RUNTIME_CONFIG__/$config_escaped_2/" -i "$main_js" +} + +inject_config + exec nginx -g 'daemon off;' diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts new file mode 100644 index 0000000000..4c7a120c63 --- /dev/null +++ b/packages/config-loader/src/loader.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { readEnv } from './loader'; + +describe('readEnv', () => { + it('should return empty config for empty env', () => { + expect(readEnv({})).toEqual([]); + }); + + it('should return empty config for no matching keys', () => { + expect( + readEnv({ + NODE_ENV: 'production', + NOPE_ENV: 'development', + APP_CONFIG: 'foo', + APP__CONFIG_derp: 'herp', + }), + ).toEqual([]); + }); + + it('should create config from env', () => { + expect( + readEnv({ + NODE_ENV: 'production', + APP_CONFIG_foo: '"bar"', + APP_CONFIG_numbers_a: '1', + APP_CONFIG_numbers_b: '2', + APP_CONFIG_numbers_c: 'false', + APP_CONFIG_numbers_d: undefined, + APP_CONFIG_very_deep_nested_config_object: '{}', + }), + ).toEqual([ + { + foo: 'bar', + numbers: { a: 1, b: 2, c: false }, + very: { deep: { nested: { config: { object: {} } } } }, + }, + ]); + }); + + it.each([ + ['APP_CONFIG__foo'], + ['APP_CONFIG_foo_'], + ['APP_CONFIG_fo_0'], + ['APP_CONFIG_fo/o'], + ['APP_CONFIG_fo o'], + ['APP_CONFIG_foo_(foo)_foo'], + ])('should reject invalid key %p', key => { + expect(() => readEnv({ [key]: '0' })).toThrow( + `Invalid env config key '${key.replace('APP_CONFIG_', '')}'`, + ); + }); + + it.each([['hello'], ['"hello'], ['{'], ['}'], ['123abc']])( + 'should reject invalid value %p', + value => { + expect(() => readEnv({ APP_CONFIG_foo: value })).toThrow( + /^Failed to parse JSON-serialized config value for key 'foo', SyntaxError: /, + ); + }, + ); + + it('should not allow null as a value', () => { + expect(() => + readEnv({ + APP_CONFIG_foo: 'null', + }), + ).toThrow( + "Failed to parse JSON-serialized config value for key 'foo', Error: value may not be null", + ); + }); + + it('should not allow duplicate values', () => { + expect(() => + readEnv({ + APP_CONFIG_foo_bar: '1', + APP_CONFIG_foo_bar_baz: '2', + }), + ).toThrow( + "Could not nest config for key 'foo_bar_baz' under existing value 'foo_bar'", + ); + }); + + it('should not allow mixing of objects and other values', () => { + expect(() => + readEnv({ + APP_CONFIG_nested_foo: '1', + APP_CONFIG_nested: '2', + }), + ).toThrow("Refusing to override existing config at key 'nested'"); + }); +}); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 006d241e8a..c8b5bb538d 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -17,12 +17,68 @@ import fs from 'fs-extra'; import yaml from 'yaml'; import { resolve as resolvePath } from 'path'; -import { AppConfig } from '@backstage/config'; +import { AppConfig, JsonObject } from '@backstage/config'; import { findRootPath } from './paths'; import { LoadConfigOptions } from './types'; -export async function loadConfig( - options: LoadConfigOptions = {}, +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; + +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] : []; +} + +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. @@ -42,3 +98,14 @@ export async function loadConfig( throw new Error(`Failed to read static configuration file, ${error}`); } } + +export async function loadConfig( + options: LoadConfigOptions = {}, +): Promise { + const configs = []; + + configs.push(...readEnv(process.env)); + configs.push(...(await readStaticConfig(options))); + + return configs; +} diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index dfdf4cd921..95c4f47383 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -16,6 +16,7 @@ import { AppConfig, Config, JsonValue, JsonObject } from './types'; +// Update the same pattern in config-loader package if this is changed const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; function isObject(value: JsonValue | undefined): value is JsonObject {