config-loader: add config file watching support

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-08-08 17:12:37 +02:00
parent 0bccb484c3
commit 9b8cec0631
4 changed files with 146 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
Add support for config file watching through a new group of `watch` options to `loadConfig`.
+1
View File
@@ -34,6 +34,7 @@
"@backstage/config": "^0.1.6",
"@types/json-schema": "^7.0.6",
"ajv": "^7.0.3",
"chokidar": "^3.5.2",
"fs-extra": "9.1.0",
"json-schema": "^0.3.0",
"json-schema-merge-allof": "^0.8.1",
+81 -2
View File
@@ -14,11 +14,13 @@
* limitations under the License.
*/
import { AppConfig } from '@backstage/config';
import { loadConfig } from './loader';
import mockFs from 'mock-fs';
import fs from 'fs-extra';
describe('loadConfig', () => {
beforeAll(() => {
beforeEach(() => {
process.env.MY_SECRET = 'is-secret';
process.env.SUBSTITUTE_ME = 'substituted';
@@ -63,7 +65,7 @@ describe('loadConfig', () => {
});
});
afterAll(() => {
afterEach(() => {
mockFs.restore();
});
@@ -170,4 +172,81 @@ describe('loadConfig', () => {
},
]);
});
it('watches config files', async () => {
const onChange = defer<AppConfig[]>();
const stopSignal = defer<void>();
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
watch: {
onChange: onChange.resolve,
stopSignal: stopSignal.promise,
},
}),
).resolves.toEqual([
{
context: 'app-config.yaml',
data: {
app: {
title: 'Example App',
sessionKey: 'abc123',
escaped: '${Escaped}',
},
},
},
]);
await fs.writeJson('/root/app-config.yaml', {
app: {
title: 'New Title',
},
});
await expect(onChange.promise).resolves.toEqual([
{
context: 'app-config.yaml',
data: {
app: {
title: 'New Title',
},
},
},
]);
stopSignal.resolve();
});
it('stops watching config files', async () => {
const stopSignal = defer<void>();
await loadConfig({
configRoot: '/root',
configPaths: [],
watch: {
onChange: () => {
expect('not').toBe('called');
},
stopSignal: stopSignal.promise,
},
});
stopSignal.resolve();
await fs.writeJson('/root/app-config.yaml', {
app: {
title: 'New Title',
},
});
await new Promise(resolve => setTimeout(resolve, 1000));
});
function defer<T>() {
let resolve: (value: T) => void;
const promise = new Promise<T>(_resolve => {
resolve = _resolve;
});
return { promise, resolve: resolve! };
}
});
+59 -5
View File
@@ -16,6 +16,7 @@
import fs from 'fs-extra';
import yaml from 'yaml';
import chokidar from 'chokidar';
import { resolve as resolvePath, dirname, isAbsolute, basename } from 'path';
import { AppConfig } from '@backstage/config';
import {
@@ -42,13 +43,27 @@ export type LoadConfigOptions = {
* @experimental This API is not stable and may change at any point
*/
experimentalEnvFunc?: EnvFunc;
/**
* An optional configuration that enables watching of config files.
*/
watch?: {
/**
* A listener that is called when a config file is changed.
*/
onChange: (configs: AppConfig[]) => void;
/**
* An optional signal that stops the watcher once the promise resolves.
*/
stopSignal?: Promise<void>;
};
};
export async function loadConfig(
options: LoadConfigOptions,
): Promise<AppConfig[]> {
const configs = [];
const { configRoot, experimentalEnvFunc: envFunc } = options;
const { configRoot, experimentalEnvFunc: envFunc, watch } = options;
const configPaths = options.configPaths.slice();
// If no paths are provided, we default to reading
@@ -64,7 +79,9 @@ export async function loadConfig(
const env = envFunc ?? (async (name: string) => process.env[name]);
try {
const loadConfigFiles = async () => {
const configs = [];
for (const configPath of configPaths) {
if (!isAbsolute(configPath)) {
throw new Error(`Config load path is not absolute: '${configPath}'`);
@@ -83,13 +100,50 @@ export async function loadConfig(
configs.push({ data, context: basename(configPath) });
}
return configs;
};
let fileConfigs;
try {
fileConfigs = await loadConfigFiles();
} catch (error) {
throw new Error(
`Failed to read static configuration file, ${error.message}`,
);
}
configs.push(...readEnvConfig(process.env));
const envConfigs = await readEnvConfig(process.env);
return configs;
// Set up config file watching if requested by the caller
if (watch) {
let currentSerializedConfig = JSON.stringify(fileConfigs);
const watcher = chokidar.watch(configPaths, {
usePolling: process.env.NODE_ENV === 'test',
});
watcher.on('change', async () => {
try {
const newConfigs = await loadConfigFiles();
const newSerializedConfig = JSON.stringify(newConfigs);
if (currentSerializedConfig === newSerializedConfig) {
return;
}
currentSerializedConfig = newSerializedConfig;
watch.onChange([...newConfigs, ...envConfigs]);
} catch (error) {
console.error(`Failed to reload configuration files, ${error}`);
}
});
if (watch.stopSignal) {
watch.stopSignal.then(() => {
watcher.close();
});
}
}
return [...fileConfigs, ...envConfigs];
}