Merge pull request #1172 from spotify/rugvip/runconf
docker,packages/config-loader: add support for runtime config injection from env
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;'
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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<AppConfig[]> {
|
||||
// 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<AppConfig[]> {
|
||||
const configs = [];
|
||||
|
||||
configs.push(...readEnv(process.env));
|
||||
configs.push(...(await readStaticConfig(options)));
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user