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:
Patrik Oldsberg
2020-06-08 15:25:07 +02:00
committed by GitHub
5 changed files with 211 additions and 3 deletions
+2
View File
@@ -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
+32
View File
@@ -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;'
+106
View File
@@ -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'");
});
});
+70 -3
View File
@@ -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;
}
+1
View File
@@ -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 {