config-loader: add config file watching support
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/config-loader': patch
|
||||
---
|
||||
|
||||
Add support for config file watching through a new group of `watch` options to `loadConfig`.
|
||||
@@ -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",
|
||||
|
||||
@@ -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! };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user