Merge pull request #17209 from backstage/rugvip/confload
config-loader: refactor config loading around a new ConfigSource interface
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Switch `configServiceFactory` to use `ConfigSources` from `@backstage/config-loader` to load config.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
'@backstage/config-loader': minor
|
||||
---
|
||||
|
||||
Introduced a new config source system to replace `loadConfig`. There is a new `ConfigSource` interface along with utilities provided by `ConfigSources`, as well as a number of built-in configuration source implementations. The new system is more flexible and makes it easier to create new and reusable sources of configuration, such as loading configuration from secret providers.
|
||||
|
||||
The following is an example of how to load configuration using the default behavior:
|
||||
|
||||
```ts
|
||||
const source = ConfigSources.default({
|
||||
argv: options?.argv,
|
||||
remote: options?.remote,
|
||||
});
|
||||
const config = await ConfigSources.toConfig(source);
|
||||
```
|
||||
|
||||
The `ConfigSource` interface looks like this:
|
||||
|
||||
```ts
|
||||
export interface ConfigSource {
|
||||
readConfigData(options?: ReadConfigDataOptions): AsyncConfigSourceIterator;
|
||||
}
|
||||
```
|
||||
|
||||
It is best implemented using an async iterator:
|
||||
|
||||
```ts
|
||||
class MyConfigSource implements ConfigSource {
|
||||
async *readConfigData() {
|
||||
yield {
|
||||
config: [
|
||||
{
|
||||
context: 'example',
|
||||
data: { backend: { baseUrl: 'http://localhost' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -26,6 +26,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { RemoteConfigSourceOptions } from '@backstage/config-loader';
|
||||
import { RequestHandler } from 'express';
|
||||
import { RequestListener } from 'http';
|
||||
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
|
||||
@@ -54,7 +55,7 @@ export const cacheServiceFactory: () => ServiceFactory<CacheClient, 'plugin'>;
|
||||
// @public (undocumented)
|
||||
export interface ConfigFactoryOptions {
|
||||
argv?: string[];
|
||||
remote?: LoadConfigOptionsRemote;
|
||||
remote?: Pick<RemoteConfigSourceOptions, 'reloadInterval'>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
+12
-7
@@ -18,8 +18,10 @@ import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LoadConfigOptionsRemote } from '@backstage/config-loader';
|
||||
import { loadBackendConfig } from '../../../config';
|
||||
import {
|
||||
ConfigSources,
|
||||
RemoteConfigSourceOptions,
|
||||
} from '@backstage/config-loader';
|
||||
|
||||
/** @public */
|
||||
export interface ConfigFactoryOptions {
|
||||
@@ -31,7 +33,7 @@ export interface ConfigFactoryOptions {
|
||||
/**
|
||||
* Enables and sets options for remote configuration loading.
|
||||
*/
|
||||
remote?: LoadConfigOptionsRemote;
|
||||
remote?: Pick<RemoteConfigSourceOptions, 'reloadInterval'>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -39,10 +41,13 @@ export const configServiceFactory = createServiceFactory(
|
||||
(options?: ConfigFactoryOptions) => ({
|
||||
service: coreServices.config,
|
||||
deps: {},
|
||||
async factory({}) {
|
||||
const { argv = process.argv, remote } = options ?? {};
|
||||
const { config } = await loadBackendConfig({ argv, remote });
|
||||
return config;
|
||||
async factory() {
|
||||
const source = ConfigSources.default({
|
||||
argv: options?.argv,
|
||||
remote: options?.remote,
|
||||
});
|
||||
console.log(`Loading config from ${source}`);
|
||||
return await ConfigSources.toConfig(source);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,45 @@
|
||||
|
||||
```ts
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { Config } from '@backstage/config';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
// @public
|
||||
export interface AsyncConfigSourceIterator
|
||||
extends AsyncIterator<
|
||||
{
|
||||
configs: ConfigSourceData[];
|
||||
},
|
||||
void,
|
||||
void
|
||||
> {
|
||||
// (undocumented)
|
||||
[Symbol.asyncIterator](): AsyncIterator<
|
||||
{
|
||||
configs: ConfigSourceData[];
|
||||
},
|
||||
void,
|
||||
void
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface BaseConfigSourcesOptions {
|
||||
// (undocumented)
|
||||
remote?: Pick<RemoteConfigSourceOptions, 'reloadInterval'>;
|
||||
// (undocumented)
|
||||
rootDir?: string;
|
||||
// (undocumented)
|
||||
substitutionFunc?: EnvFunc;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ClosableConfig extends Config {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ConfigSchema = {
|
||||
@@ -25,7 +62,55 @@ export type ConfigSchemaProcessingOptions = {
|
||||
withDeprecatedKeys?: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export interface ConfigSource {
|
||||
// (undocumented)
|
||||
readConfigData(options?: ReadConfigDataOptions): AsyncConfigSourceIterator;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ConfigSourceData extends AppConfig {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class ConfigSources {
|
||||
static default(options: ConfigSourcesDefaultOptions): ConfigSource;
|
||||
static defaultForTargets(
|
||||
options: ConfigSourcesDefaultForTargetsOptions,
|
||||
): ConfigSource;
|
||||
static merge(sources: ConfigSource[]): ConfigSource;
|
||||
static parseArgs(argv?: string[]): ConfigSourceTarget[];
|
||||
static toConfig(source: ConfigSource): Promise<ClosableConfig>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ConfigSourcesDefaultForTargetsOptions
|
||||
extends BaseConfigSourcesOptions {
|
||||
// (undocumented)
|
||||
targets: ConfigSourceTarget[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ConfigSourcesDefaultOptions extends BaseConfigSourcesOptions {
|
||||
// (undocumented)
|
||||
argv?: string[];
|
||||
// (undocumented)
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ConfigSourceTarget =
|
||||
| {
|
||||
type: 'path';
|
||||
target: string;
|
||||
}
|
||||
| {
|
||||
type: 'url';
|
||||
target: string;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type ConfigTarget =
|
||||
| {
|
||||
path: string;
|
||||
@@ -38,11 +123,43 @@ export type ConfigTarget =
|
||||
export type ConfigVisibility = 'frontend' | 'backend' | 'secret';
|
||||
|
||||
// @public
|
||||
export class EnvConfigSource implements ConfigSource {
|
||||
static create(options: EnvConfigSourceOptions): ConfigSource;
|
||||
// (undocumented)
|
||||
readConfigData(): AsyncConfigSourceIterator;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EnvConfigSourceOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type EnvFunc = (name: string) => Promise<string | undefined>;
|
||||
|
||||
// @public
|
||||
export class FileConfigSource implements ConfigSource {
|
||||
static create(options: FileConfigSourceOptions): ConfigSource;
|
||||
// (undocumented)
|
||||
readConfigData(options?: ReadConfigDataOptions): AsyncConfigSourceIterator;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FileConfigSourceOptions {
|
||||
path: string;
|
||||
substitutionFunc?: EnvFunc;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function loadConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<LoadConfigResult>;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type LoadConfigOptions = {
|
||||
configRoot: string;
|
||||
configTargets: ConfigTarget[];
|
||||
@@ -51,18 +168,18 @@ export type LoadConfigOptions = {
|
||||
watch?: LoadConfigOptionsWatch;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type LoadConfigOptionsRemote = {
|
||||
reloadIntervalSeconds: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type LoadConfigOptionsWatch = {
|
||||
onChange: (configs: AppConfig[]) => void;
|
||||
stopSignal?: Promise<void>;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type LoadConfigResult = {
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
@@ -86,10 +203,76 @@ export type LoadConfigSchemaOptions =
|
||||
export function mergeConfigSchemas(schemas: JSONSchema7[]): JSONSchema7;
|
||||
|
||||
// @public
|
||||
export class MutableConfigSource implements ConfigSource {
|
||||
close(): void;
|
||||
static create(options?: MutableConfigSourceOptions): MutableConfigSource;
|
||||
// (undocumented)
|
||||
readConfigData(
|
||||
options?: ReadConfigDataOptions | undefined,
|
||||
): AsyncConfigSourceIterator;
|
||||
setData(data: JsonObject): void;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface MutableConfigSourceOptions {
|
||||
// (undocumented)
|
||||
context?: string;
|
||||
// (undocumented)
|
||||
data?: JsonObject;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ReadConfigDataOptions {
|
||||
// (undocumented)
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function readEnvConfig(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[];
|
||||
|
||||
// @public
|
||||
export class RemoteConfigSource implements ConfigSource {
|
||||
static create(options: RemoteConfigSourceOptions): ConfigSource;
|
||||
// (undocumented)
|
||||
readConfigData(
|
||||
options?: ReadConfigDataOptions | undefined,
|
||||
): AsyncConfigSourceIterator;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RemoteConfigSourceOptions {
|
||||
reloadInterval?: HumanDuration;
|
||||
substitutionFunc?: EnvFunc;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class StaticConfigSource implements ConfigSource {
|
||||
static create(options: StaticConfigSourceOptions): ConfigSource;
|
||||
// (undocumented)
|
||||
readConfigData(): AsyncConfigSourceIterator;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface StaticConfigSourceOptions {
|
||||
// (undocumented)
|
||||
context?: string;
|
||||
// (undocumented)
|
||||
data:
|
||||
| JsonObject
|
||||
| Observable<JsonObject>
|
||||
| PromiseLike<JsonObject>
|
||||
| AsyncIterable<JsonObject>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TransformFunc<T extends number | string | boolean> = (
|
||||
value: T,
|
||||
|
||||
@@ -44,19 +44,23 @@
|
||||
"json-schema": "^0.4.0",
|
||||
"json-schema-merge-allof": "^0.8.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.5",
|
||||
"node-fetch": "^2.6.7",
|
||||
"typescript-json-schema": "^0.55.0",
|
||||
"yaml": "^2.0.0",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/json-schema-merge-allof": "^0.6.0",
|
||||
"@types/mock-fs": "^4.10.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/yup": "^0.29.13",
|
||||
"mock-fs": "^5.1.0",
|
||||
"msw": "^1.0.0"
|
||||
"msw": "^1.0.0",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { readEnvConfig, loadConfigSchema, mergeConfigSchemas } from './lib';
|
||||
export { loadConfigSchema, mergeConfigSchemas } from './schema';
|
||||
export type {
|
||||
ConfigSchema,
|
||||
ConfigSchemaProcessingOptions,
|
||||
ConfigVisibility,
|
||||
LoadConfigSchemaOptions,
|
||||
TransformFunc,
|
||||
} from './lib';
|
||||
} from './schema';
|
||||
export { loadConfig } from './loader';
|
||||
export type {
|
||||
ConfigTarget,
|
||||
@@ -36,3 +36,4 @@ export type {
|
||||
LoadConfigOptionsRemote,
|
||||
LoadConfigResult,
|
||||
} from './loader';
|
||||
export * from './sources';
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { readEnvConfig } from './env';
|
||||
export * from './transform';
|
||||
export * from './schema';
|
||||
export { isValidUrl } from './urls';
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { isValidUrl } from './urls';
|
||||
|
||||
describe('isValidUrl', () => {
|
||||
it('should return true for url', () => {
|
||||
const validUrl = isValidUrl('http://some.valid.url');
|
||||
expect(validUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for absolute path', () => {
|
||||
const validUrl = isValidUrl('/some/absolute/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for relative path', () => {
|
||||
const validUrl = isValidUrl('../some/relative/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -186,6 +187,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
{
|
||||
context: 'app-config2.yaml',
|
||||
@@ -196,6 +198,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config2.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -218,6 +221,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -243,6 +247,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
{
|
||||
context: 'app-config.development.yaml',
|
||||
@@ -259,6 +264,7 @@ describe('loadConfig', () => {
|
||||
secret: 'abc123',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.development.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -282,6 +288,7 @@ describe('loadConfig', () => {
|
||||
noSubstitute: 'notSubstituted',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.substitute.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -311,6 +318,7 @@ describe('loadConfig', () => {
|
||||
escaped: '${Escaped}',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -328,6 +336,7 @@ describe('loadConfig', () => {
|
||||
title: 'New Title',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.yaml',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -364,6 +373,7 @@ describe('loadConfig', () => {
|
||||
secret: 'abc123',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.development.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -388,6 +398,7 @@ describe('loadConfig', () => {
|
||||
secret: 'abc234',
|
||||
},
|
||||
},
|
||||
path: '/root/app-config.development.yaml',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -14,25 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import chokidar from 'chokidar';
|
||||
import { basename, dirname, isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import {
|
||||
applyConfigTransforms,
|
||||
createIncludeTransform,
|
||||
createSubstitutionTransform,
|
||||
isValidUrl,
|
||||
readEnvConfig,
|
||||
} from './lib';
|
||||
import fetch from 'node-fetch';
|
||||
import { ConfigSources } from './sources';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export type ConfigTarget = { path: string } | { url: string };
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export type LoadConfigOptionsWatch = {
|
||||
/**
|
||||
* A listener that is called when a config file is changed.
|
||||
@@ -45,7 +39,10 @@ export type LoadConfigOptionsWatch = {
|
||||
stopSignal?: Promise<void>;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export type LoadConfigOptionsRemote = {
|
||||
/**
|
||||
* A remote config reloading period, in seconds
|
||||
@@ -57,6 +54,7 @@ export type LoadConfigOptionsRemote = {
|
||||
* Options that control the loading of configuration files in the backend.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export type LoadConfigOptions = {
|
||||
// The root directory of the config loading context. Used to find default configs.
|
||||
@@ -86,6 +84,7 @@ export type LoadConfigOptions = {
|
||||
/**
|
||||
* Results of loading configuration files.
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export type LoadConfigResult = {
|
||||
/**
|
||||
@@ -98,227 +97,55 @@ export type LoadConfigResult = {
|
||||
* Load configuration data.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ConfigSources.default} instead.
|
||||
*/
|
||||
export async function loadConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<LoadConfigResult> {
|
||||
const { configRoot, experimentalEnvFunc: envFunc, watch, remote } = options;
|
||||
const source = ConfigSources.default({
|
||||
substitutionFunc: options.experimentalEnvFunc,
|
||||
remote: options.remote && {
|
||||
reloadInterval: { seconds: options.remote.reloadIntervalSeconds },
|
||||
},
|
||||
rootDir: options.configRoot,
|
||||
argv: options.configTargets.flatMap(t => [
|
||||
'--config',
|
||||
'url' in t ? t.url : t.path,
|
||||
]),
|
||||
});
|
||||
|
||||
const configPaths: string[] = options.configTargets
|
||||
.slice()
|
||||
.filter((e): e is { path: string } => e.hasOwnProperty('path'))
|
||||
.map(configTarget => configTarget.path);
|
||||
return new Promise<LoadConfigResult>((resolve, reject) => {
|
||||
async function loadConfigReaderLoop() {
|
||||
let loaded = false;
|
||||
|
||||
const configUrls: string[] = options.configTargets
|
||||
.slice()
|
||||
.filter((e): e is { url: string } => e.hasOwnProperty('url'))
|
||||
.map(configTarget => configTarget.url);
|
||||
|
||||
if (remote === undefined) {
|
||||
if (configUrls.length > 0) {
|
||||
throw new Error(
|
||||
`Please make sure you are passing the remote option when loading remote configurations. See https://backstage.io/docs/conf/writing#configuration-files for detailed info.`,
|
||||
);
|
||||
}
|
||||
} else if (remote.reloadIntervalSeconds <= 0) {
|
||||
throw new Error(
|
||||
`Remote config must be contain a non zero reloadIntervalSeconds: <seconds> value`,
|
||||
);
|
||||
}
|
||||
|
||||
// If no paths are provided, we default to reading
|
||||
// `app-config.yaml` and, if it exists, `app-config.local.yaml`
|
||||
if (configPaths.length === 0 && configUrls.length === 0) {
|
||||
configPaths.push(resolvePath(configRoot, 'app-config.yaml'));
|
||||
|
||||
const localConfig = resolvePath(configRoot, 'app-config.local.yaml');
|
||||
if (await fs.pathExists(localConfig)) {
|
||||
configPaths.push(localConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const env = envFunc ?? (async (name: string) => process.env[name]);
|
||||
|
||||
const loadConfigFiles = async () => {
|
||||
const fileConfigs = [];
|
||||
const loadedPaths = new Set<string>();
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
if (!isAbsolute(configPath)) {
|
||||
throw new Error(`Config load path is not absolute: '${configPath}'`);
|
||||
}
|
||||
|
||||
const dir = dirname(configPath);
|
||||
const readFile = (path: string) => {
|
||||
const fullPath = resolvePath(dir, path);
|
||||
// if we read a file when building configuration,
|
||||
// we should include that file when watching for
|
||||
// changes, too.
|
||||
loadedPaths.add(fullPath);
|
||||
|
||||
return fs.readFile(fullPath, 'utf8');
|
||||
};
|
||||
|
||||
const input = yaml.parse(await readFile(configPath));
|
||||
|
||||
// A completely empty file ends up as a null return value
|
||||
if (input !== null) {
|
||||
const substitutionTransform = createSubstitutionTransform(env);
|
||||
const data = await applyConfigTransforms(dir, input, [
|
||||
createIncludeTransform(env, readFile, substitutionTransform),
|
||||
substitutionTransform,
|
||||
]);
|
||||
|
||||
fileConfigs.push({ data, context: basename(configPath) });
|
||||
}
|
||||
}
|
||||
|
||||
return { fileConfigs, loadedPaths };
|
||||
};
|
||||
|
||||
const loadRemoteConfigFiles = async () => {
|
||||
const configs: AppConfig[] = [];
|
||||
|
||||
const readConfigFromUrl = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not read config file at ${url}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
for (let i = 0; i < configUrls.length; i++) {
|
||||
const configUrl = configUrls[i];
|
||||
if (!isValidUrl(configUrl)) {
|
||||
throw new Error(`Config load path is not valid: '${configUrl}'`);
|
||||
}
|
||||
|
||||
const remoteConfigContent = await readConfigFromUrl(configUrl);
|
||||
if (!remoteConfigContent) {
|
||||
throw new Error(`Config is not valid`);
|
||||
}
|
||||
const configYaml = yaml.parse(remoteConfigContent);
|
||||
const substitutionTransform = createSubstitutionTransform(env);
|
||||
const data = await applyConfigTransforms(configRoot, configYaml, [
|
||||
substitutionTransform,
|
||||
]);
|
||||
|
||||
configs.push({ data, context: configUrl });
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
let fileConfigs: AppConfig[];
|
||||
let loadedPaths: Set<string>;
|
||||
try {
|
||||
({ fileConfigs, loadedPaths } = await loadConfigFiles());
|
||||
} catch (error) {
|
||||
throw new ForwardedError('Failed to read static configuration file', error);
|
||||
}
|
||||
|
||||
let remoteConfigs: AppConfig[] = [];
|
||||
if (remote) {
|
||||
try {
|
||||
remoteConfigs = await loadRemoteConfigFiles();
|
||||
} catch (error) {
|
||||
throw new ForwardedError(
|
||||
`Failed to read remote configuration file`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const envConfigs = readEnvConfig(process.env);
|
||||
|
||||
const watchConfigFile = (watchProp: LoadConfigOptionsWatch) => {
|
||||
let watchedFiles = Array.from(loadedPaths);
|
||||
|
||||
const watcher = chokidar.watch(watchedFiles, {
|
||||
usePolling: process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
let currentSerializedConfig = JSON.stringify(fileConfigs);
|
||||
watcher.on('change', async () => {
|
||||
try {
|
||||
const { fileConfigs: newConfigs, loadedPaths: newLoadedPaths } =
|
||||
await loadConfigFiles();
|
||||
const abortController = new AbortController();
|
||||
options.watch?.stopSignal?.then(() => abortController.abort());
|
||||
|
||||
// Replace watches to handle any added or removed
|
||||
// $include or $file expressions.
|
||||
watcher.unwatch(watchedFiles);
|
||||
watchedFiles = Array.from(newLoadedPaths);
|
||||
watcher.add(watchedFiles);
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
if (loaded) {
|
||||
options.watch?.onChange(configs);
|
||||
} else {
|
||||
resolve({ appConfigs: configs });
|
||||
loaded = true;
|
||||
|
||||
const newSerializedConfig = JSON.stringify(newConfigs);
|
||||
|
||||
if (currentSerializedConfig === newSerializedConfig) {
|
||||
return;
|
||||
if (options.watch) {
|
||||
options.watch.stopSignal?.then(() => abortController.abort());
|
||||
} else {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
currentSerializedConfig = newSerializedConfig;
|
||||
|
||||
watchProp.onChange([...remoteConfigs, ...newConfigs, ...envConfigs]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to reload configuration files, ${error}`);
|
||||
if (loaded) {
|
||||
console.error(`Failed to reload configuration, ${error}`);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (watchProp.stopSignal) {
|
||||
watchProp.stopSignal.then(() => {
|
||||
watcher.close();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const watchRemoteConfig = (
|
||||
watchProp: LoadConfigOptionsWatch,
|
||||
remoteProp: LoadConfigOptionsRemote,
|
||||
) => {
|
||||
const hasConfigChanged = async (
|
||||
oldRemoteConfigs: AppConfig[],
|
||||
newRemoteConfigs: AppConfig[],
|
||||
) => {
|
||||
return (
|
||||
JSON.stringify(oldRemoteConfigs) !== JSON.stringify(newRemoteConfigs)
|
||||
);
|
||||
};
|
||||
|
||||
let handle: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
handle = setInterval(async () => {
|
||||
const newRemoteConfigs = await loadRemoteConfigFiles();
|
||||
if (await hasConfigChanged(remoteConfigs, newRemoteConfigs)) {
|
||||
remoteConfigs = newRemoteConfigs;
|
||||
watchProp.onChange([...remoteConfigs, ...fileConfigs, ...envConfigs]);
|
||||
}
|
||||
}, remoteProp.reloadIntervalSeconds * 1000);
|
||||
} catch (error) {
|
||||
console.error(`Failed to reload configuration files, ${error}`);
|
||||
}
|
||||
|
||||
if (watchProp.stopSignal) {
|
||||
watchProp.stopSignal.then(() => {
|
||||
if (handle !== undefined) {
|
||||
clearInterval(handle);
|
||||
handle = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Set up config file watching if requested by the caller
|
||||
if (watch) {
|
||||
watchConfigFile(watch);
|
||||
}
|
||||
|
||||
if (watch && remote) {
|
||||
watchRemoteConfig(watch, remote);
|
||||
}
|
||||
|
||||
return {
|
||||
appConfigs: remote
|
||||
? [...remoteConfigs, ...fileConfigs, ...envConfigs]
|
||||
: [...fileConfigs, ...envConfigs],
|
||||
};
|
||||
loadConfigReaderLoop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { ConfigSources } from './ConfigSources';
|
||||
import { ConfigSource } from './types';
|
||||
import { MutableConfigSource } from './MutableConfigSource';
|
||||
|
||||
jest.mock('./FileConfigSource', () => ({
|
||||
FileConfigSource: {
|
||||
create: (opts: {}) => ({ ...opts, name: 'FileConfigSource' }),
|
||||
},
|
||||
}));
|
||||
jest.mock('./RemoteConfigSource', () => ({
|
||||
RemoteConfigSource: {
|
||||
create: (opts: {}) => ({ ...opts, name: 'RemoteConfigSource' }),
|
||||
},
|
||||
}));
|
||||
jest.mock('./EnvConfigSource', () => ({
|
||||
EnvConfigSource: {
|
||||
create: (opts: {}) => ({ ...opts, name: 'EnvConfigSource' }),
|
||||
},
|
||||
}));
|
||||
|
||||
function mergeSources(source: ConfigSource): ConfigSource[] {
|
||||
return (source as any)[
|
||||
Symbol.for('@backstage/config-loader#MergedConfigSource.sources')
|
||||
] as ConfigSource[];
|
||||
}
|
||||
|
||||
describe('ConfigSources', () => {
|
||||
it('should parse args', () => {
|
||||
expect(ConfigSources.parseArgs([])).toEqual([]);
|
||||
|
||||
expect(
|
||||
ConfigSources.parseArgs(['--config', 'a.yaml', '--config=b.yaml']),
|
||||
).toEqual([
|
||||
{ type: 'path', target: 'a.yaml' },
|
||||
{ type: 'path', target: 'b.yaml' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
ConfigSources.parseArgs([
|
||||
'--config',
|
||||
'a.yaml',
|
||||
'--config',
|
||||
'http://example.com/config.yaml',
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: 'path', target: 'a.yaml' },
|
||||
{ type: 'url', target: 'http://example.com/config.yaml' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create default sources for targets', () => {
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }),
|
||||
),
|
||||
).toEqual([{ name: 'FileConfigSource', path: '/app-config.yaml' }]);
|
||||
|
||||
const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockReturnValue(true);
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: 'FileConfigSource', path: '/app-config.yaml' },
|
||||
{ name: 'FileConfigSource', path: '/app-config.local.yaml' },
|
||||
]);
|
||||
fsSpy.mockRestore();
|
||||
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [{ type: 'path', target: '/config.yaml' }],
|
||||
}),
|
||||
),
|
||||
).toEqual([{ name: 'FileConfigSource', path: '/config.yaml' }]);
|
||||
|
||||
const subFunc = async () => undefined;
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [{ type: 'path', target: '/config.yaml' }],
|
||||
substitutionFunc: subFunc,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'FileConfigSource',
|
||||
path: '/config.yaml',
|
||||
substitutionFunc: subFunc,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [{ type: 'url', target: 'http://example.com/config.yaml' }],
|
||||
}),
|
||||
).toThrow(
|
||||
'Config argument "http://example.com/config.yaml" looks like a URL but remote configuration is not enabled. Enable it by passing the `remote` option',
|
||||
);
|
||||
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [{ type: 'url', target: 'http://example.com/config.yaml' }],
|
||||
remote: {},
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: 'RemoteConfigSource', url: 'http://example.com/config.yaml' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [{ type: 'url', target: 'http://example.com/config.yaml' }],
|
||||
remote: { reloadInterval: { minutes: 2 } },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'RemoteConfigSource',
|
||||
url: 'http://example.com/config.yaml',
|
||||
reloadInterval: { minutes: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a default source', () => {
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.default({
|
||||
rootDir: '/',
|
||||
env: { HOME: '/' },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: 'FileConfigSource', path: '/app-config.yaml' },
|
||||
{ name: 'EnvConfigSource', env: { HOME: '/' } },
|
||||
]);
|
||||
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.default({
|
||||
rootDir: '/',
|
||||
argv: ['--config', 'a.yaml', '--config=b.yaml'],
|
||||
env: { HOME: '/' },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: 'FileConfigSource', path: 'a.yaml' },
|
||||
{ name: 'FileConfigSource', path: 'b.yaml' },
|
||||
{ name: 'EnvConfigSource', env: { HOME: '/' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge sources', () => {
|
||||
expect(
|
||||
mergeSources(
|
||||
ConfigSources.merge([
|
||||
ConfigSources.defaultForTargets({
|
||||
rootDir: '/',
|
||||
targets: [
|
||||
{ type: 'path', target: '/a.yaml' },
|
||||
{ type: 'path', target: '/b.yaml' },
|
||||
{ type: 'path', target: '/c.yaml' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: 'FileConfigSource', path: '/a.yaml' },
|
||||
{ name: 'FileConfigSource', path: '/b.yaml' },
|
||||
{ name: 'FileConfigSource', path: '/c.yaml' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create an observable config', async () => {
|
||||
const source = MutableConfigSource.create({ data: { a: 1 } });
|
||||
const config = await ConfigSources.toConfig(source);
|
||||
const listener = jest.fn();
|
||||
const sub = config.subscribe?.(listener);
|
||||
|
||||
expect(config.getNumber('a')).toBe(1);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
source.setData({ a: 2 });
|
||||
await new Promise<void>(resolve => setTimeout(resolve));
|
||||
expect(config.getNumber('a')).toBe(2);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
sub?.unsubscribe();
|
||||
source.setData({ a: 3 });
|
||||
await new Promise<void>(resolve => setTimeout(resolve));
|
||||
expect(config.getNumber('a')).toBe(3);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
config.close();
|
||||
source.setData({ a: 4 });
|
||||
await new Promise<void>(resolve => setTimeout(resolve));
|
||||
expect(config.getNumber('a')).toBe(3);
|
||||
});
|
||||
|
||||
it('should fail to create config', async () => {
|
||||
await expect(
|
||||
ConfigSources.toConfig({
|
||||
async *readConfigData() {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('NOPE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { resolve as resolvePath } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import parseArgs from 'minimist';
|
||||
import { EnvConfigSource } from './EnvConfigSource';
|
||||
import { FileConfigSource } from './FileConfigSource';
|
||||
import { MergedConfigSource } from './MergedConfigSource';
|
||||
import {
|
||||
RemoteConfigSource,
|
||||
RemoteConfigSourceOptions,
|
||||
} from './RemoteConfigSource';
|
||||
import { ConfigSource, SubstitutionFunc } from './types';
|
||||
import { ObservableConfigProxy } from './ObservableConfigProxy';
|
||||
import { findPaths } from '@backstage/cli-common';
|
||||
|
||||
/**
|
||||
* A target to read configuration from.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ConfigSourceTarget =
|
||||
| {
|
||||
type: 'path';
|
||||
target: string;
|
||||
}
|
||||
| {
|
||||
type: 'url';
|
||||
target: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A config implementation that can be closed.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Closing the configuration instance will stop the reading from the underlying source.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ClosableConfig extends Config {
|
||||
/**
|
||||
* Closes the configuration instance.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The configuration instance will still be usable after closing, but it will
|
||||
* no longer be updated with new values from the underlying source.
|
||||
*/
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common options for the default Backstage configuration sources.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BaseConfigSourcesOptions {
|
||||
rootDir?: string;
|
||||
remote?: Pick<RemoteConfigSourceOptions, 'reloadInterval'>;
|
||||
substitutionFunc?: SubstitutionFunc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link ConfigSources.defaultForTargets}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ConfigSourcesDefaultForTargetsOptions
|
||||
extends BaseConfigSourcesOptions {
|
||||
targets: ConfigSourceTarget[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link ConfigSources.default}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ConfigSourcesDefaultOptions extends BaseConfigSourcesOptions {
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of utilities for working with and creating {@link ConfigSource}s.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class ConfigSources {
|
||||
/**
|
||||
* Parses command line arguments and returns the config targets.
|
||||
*
|
||||
* @param argv - The command line arguments to parse. Defaults to `process.argv`
|
||||
* @returns A list of config targets
|
||||
*/
|
||||
static parseArgs(argv: string[] = process.argv): ConfigSourceTarget[] {
|
||||
const args: string[] = [parseArgs(argv).config].flat().filter(Boolean);
|
||||
return args.map(target => {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
|
||||
// Some file paths are valid relative URLs, so check if the host is empty too
|
||||
if (!url.host) {
|
||||
return { type: 'path', target };
|
||||
}
|
||||
return { type: 'url', target };
|
||||
} catch {
|
||||
return { type: 'path', target };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the default config sources for the provided targets.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This will create {@link FileConfigSource}s and {@link RemoteConfigSource}s
|
||||
* for the provided targets, and merge them together to a single source.
|
||||
* If no targets are provided it will fall back to `app-config.yaml` and
|
||||
* `app-config.local.yaml`.
|
||||
*
|
||||
* URL targets are only supported if the `remote` option is provided.
|
||||
*
|
||||
* @param options - Options
|
||||
* @returns A config source for the provided targets
|
||||
*/
|
||||
static defaultForTargets(
|
||||
options: ConfigSourcesDefaultForTargetsOptions,
|
||||
): ConfigSource {
|
||||
const rootDir = options.rootDir ?? findPaths(process.cwd()).targetRoot;
|
||||
|
||||
const argSources = options.targets.map(arg => {
|
||||
if (arg.type === 'url') {
|
||||
if (!options.remote) {
|
||||
throw new Error(
|
||||
`Config argument "${arg.target}" looks like a URL but remote configuration is not enabled. Enable it by passing the \`remote\` option`,
|
||||
);
|
||||
}
|
||||
return RemoteConfigSource.create({
|
||||
url: arg.target,
|
||||
substitutionFunc: options.substitutionFunc,
|
||||
reloadInterval: options.remote.reloadInterval,
|
||||
});
|
||||
}
|
||||
return FileConfigSource.create({
|
||||
path: arg.target,
|
||||
substitutionFunc: options.substitutionFunc,
|
||||
});
|
||||
});
|
||||
|
||||
if (argSources.length === 0) {
|
||||
const defaultPath = resolvePath(rootDir, 'app-config.yaml');
|
||||
const localPath = resolvePath(rootDir, 'app-config.local.yaml');
|
||||
|
||||
argSources.push(
|
||||
FileConfigSource.create({
|
||||
path: defaultPath,
|
||||
substitutionFunc: options.substitutionFunc,
|
||||
}),
|
||||
);
|
||||
if (fs.pathExistsSync(localPath)) {
|
||||
argSources.push(
|
||||
FileConfigSource.create({
|
||||
path: localPath,
|
||||
substitutionFunc: options.substitutionFunc,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.merge(argSources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the default config source for Backstage.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This will read from `app-config.yaml` and `app-config.local.yaml` by
|
||||
* default, as well as environment variables prefixed with `APP_CONFIG_`.
|
||||
* If `--config <path|url>` command line arguments are passed, these will
|
||||
* override the default configuration file paths. URLs are only supported
|
||||
* if the `remote` option is provided.
|
||||
*
|
||||
* @param options - Options
|
||||
* @returns The default Backstage config source
|
||||
*/
|
||||
static default(options: ConfigSourcesDefaultOptions): ConfigSource {
|
||||
const argSource = this.defaultForTargets({
|
||||
...options,
|
||||
targets: this.parseArgs(options.argv),
|
||||
});
|
||||
|
||||
const envSource = EnvConfigSource.create({
|
||||
env: options.env,
|
||||
});
|
||||
|
||||
return this.merge([argSource, envSource]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple config sources into a single source that reads from all
|
||||
* sources and concatenates the result.
|
||||
*
|
||||
* @param sources - The config sources to merge
|
||||
* @returns A single config source that concatenates the data from the given sources
|
||||
*/
|
||||
static merge(sources: ConfigSource[]): ConfigSource {
|
||||
return MergedConfigSource.from(sources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an observable {@link @backstage/config#Config} implementation from a {@link ConfigSource}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If you only want to read the config once you can close the returned config immediately.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const sources = ConfigSources.default(...)
|
||||
* const config = await ConfigSources.toConfig(source)
|
||||
* config.close()
|
||||
* const example = config.getString(...)
|
||||
* ```
|
||||
*
|
||||
* @param source - The config source to read from
|
||||
* @returns A promise that resolves to a closable config
|
||||
*/
|
||||
static toConfig(source: ConfigSource): Promise<ClosableConfig> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let config: ObservableConfigProxy | undefined = undefined;
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
if (config) {
|
||||
config.setConfig(ConfigReader.fromConfigs(configs));
|
||||
} else {
|
||||
config = ObservableConfigProxy.create(abortController);
|
||||
config!.setConfig(ConfigReader.fromConfigs(configs));
|
||||
resolve(config);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -14,7 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { readEnvConfig } from './env';
|
||||
import { EnvConfigSource, readEnvConfig } from './EnvConfigSource';
|
||||
import { ConfigSource, ConfigSourceData } from './types';
|
||||
|
||||
async function readAll(source: ConfigSource) {
|
||||
const entries = new Array<{ configs: ConfigSourceData[] }>();
|
||||
for await (const item of source.readConfigData()) {
|
||||
entries.push(item);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('EnvConfigSource', () => {
|
||||
it('should return empty config for empty env', async () => {
|
||||
const source = EnvConfigSource.create({ env: {} });
|
||||
|
||||
await expect(readAll(source)).resolves.toEqual([{ configs: [] }]);
|
||||
});
|
||||
|
||||
it('should forward config values', async () => {
|
||||
const source = EnvConfigSource.create({ env: { APP_CONFIG_foo: 'bar' } });
|
||||
|
||||
await expect(readAll(source)).resolves.toEqual([
|
||||
{ configs: [{ context: 'env', data: { foo: 'bar' } }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEnvConfig', () => {
|
||||
it('should return empty config for empty env', () => {
|
||||
+75
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -15,16 +15,26 @@
|
||||
*/
|
||||
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { assertError } from '@backstage/errors';
|
||||
|
||||
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;
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { AsyncConfigSourceIterator, ConfigSource } from './types';
|
||||
|
||||
/**
|
||||
* Read runtime configuration from the environment.
|
||||
* Options for {@link EnvConfigSource.create}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EnvConfigSourceOptions {
|
||||
/**
|
||||
* The environment variables to use, defaults to `process.env`.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A config source that reads configuration from the environment.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Only environment variables prefixed with APP_CONFIG_ will be considered.
|
||||
*
|
||||
@@ -43,6 +53,63 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class EnvConfigSource implements ConfigSource {
|
||||
/**
|
||||
* Creates a new config source that reads from the environment.
|
||||
*
|
||||
* @param options - Options for the config source.
|
||||
* @returns A new config source that reads from the environment.
|
||||
*/
|
||||
static create(options: EnvConfigSourceOptions): ConfigSource {
|
||||
return new EnvConfigSource(options?.env ?? process.env);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly env: { [name: string]: string | undefined },
|
||||
) {}
|
||||
|
||||
async *readConfigData(): AsyncConfigSourceIterator {
|
||||
const configs = readEnvConfig(this.env);
|
||||
yield { configs };
|
||||
return;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const keys = Object.keys(this.env).filter(key =>
|
||||
key.startsWith('APP_CONFIG_'),
|
||||
);
|
||||
return `EnvConfigSource{count=${keys.length}}`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Read runtime configuration from the environment.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Only environment variables prefixed with APP_CONFIG_ will be considered.
|
||||
*
|
||||
* For each variable, the prefix will be removed, and rest of the key will
|
||||
* be split by '_'. Each part will then be used as keys to build up a nested
|
||||
* config object structure. The treatment of the entire environment variable
|
||||
* is case-sensitive.
|
||||
*
|
||||
* The value of the variable should be JSON serialized, as it will be parsed
|
||||
* and the type will be kept intact. For example "true" and true are treated
|
||||
* differently, as well as "42" and 42.
|
||||
*
|
||||
* For example, to set the config app.title to "My Title", use the following:
|
||||
*
|
||||
* APP_CONFIG_app_title='"My Title"'
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link EnvConfigSource} instead
|
||||
*/
|
||||
export function readEnvConfig(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { FileConfigSource } from './FileConfigSource';
|
||||
import { readN } from './__testUtils__/testUtils';
|
||||
|
||||
const tmpDirs = new Array<string>();
|
||||
|
||||
async function tmpFiles(files: Record<string, string>) {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
resolvePath(os.tmpdir(), 'backstage-unit-test-fixture-'),
|
||||
);
|
||||
tmpDirs.push(tmpDir);
|
||||
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
await fs.writeFile(resolvePath(tmpDir, name), content, 'utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
resolve(...paths: string[]) {
|
||||
return resolvePath(tmpDir, ...paths);
|
||||
},
|
||||
write: async (name: string, content: string) => {
|
||||
await fs.writeFile(resolvePath(tmpDir, name), content, 'utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileConfigSource', () => {
|
||||
afterEach(async () => {
|
||||
for (const tmpDir of tmpDirs) {
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should read a config file', async () => {
|
||||
const tmp = await tmpFiles({ 'a.yaml': 'a: 1' });
|
||||
|
||||
const source = FileConfigSource.create({ path: tmp.resolve('a.yaml') });
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should watch config files', async () => {
|
||||
const tmp = await tmpFiles({ 'a.yaml': 'a: 1' });
|
||||
|
||||
const source = FileConfigSource.create({ path: tmp.resolve('a.yaml') });
|
||||
|
||||
setTimeout(() => {
|
||||
tmp.write('a.yaml', 'a: 2');
|
||||
}, 10);
|
||||
|
||||
await expect(readN(source, 2)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
[{ data: { a: 2 }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include files', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $include: x.yaml }',
|
||||
'x.yaml': '3',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({ path: tmp.resolve('a.yaml') });
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([
|
||||
[{ data: { a: 3 }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include with substitution', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $include: "${MY_FILE}.yaml" } ',
|
||||
'x.yaml': '4',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
substitutionFunc: async name => (name === 'MY_FILE' ? 'x' : undefined),
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([
|
||||
[{ data: { a: 4 }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should substitute in include', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $include: x.yaml }',
|
||||
'x.yaml': '${MY_VALUE}',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
substitutionFunc: async name => (name === 'MY_VALUE' ? '5' : undefined),
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([
|
||||
[{ data: { a: '5' }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should watch included files', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $include: x.yaml }',
|
||||
'x.yaml': '${MY_VALUE}',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
substitutionFunc: async name => (name === 'MY_VALUE' ? '6' : '7'),
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
tmp.write('x.yaml', '${MY_OTHER_VALUE}');
|
||||
}, 10);
|
||||
|
||||
await expect(readN(source, 2)).resolves.toEqual([
|
||||
[{ data: { a: '6' }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
[{ data: { a: '7' }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should watch referenced files', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $file: x.txt }',
|
||||
'x.txt': '8',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
tmp.write('x.txt', '9');
|
||||
}, 10);
|
||||
|
||||
await expect(readN(source, 2)).resolves.toEqual([
|
||||
[{ data: { a: '8' }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
[{ data: { a: '9' }, context: 'a.yaml', path: tmp.resolve('a.yaml') }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore empty files', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': '',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([[]]);
|
||||
});
|
||||
|
||||
it('should error on file', async () => {
|
||||
const tmp = await tmpFiles({});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('not-found.yaml'),
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).rejects.toThrow(
|
||||
`Config file "${tmp.resolve('not-found.yaml')}" does not exist`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on missing include', async () => {
|
||||
const tmp = await tmpFiles({
|
||||
'a.yaml': 'a: { $include: not-found.yaml } ',
|
||||
});
|
||||
|
||||
const source = FileConfigSource.create({
|
||||
path: tmp.resolve('a.yaml'),
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).rejects.toThrow(
|
||||
`Failed to read config file at "${tmp.resolve(
|
||||
'a.yaml',
|
||||
)}", error at .a, failed to include "${tmp.resolve(
|
||||
'not-found.yaml',
|
||||
)}", file does not exist`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse relative paths', async () => {
|
||||
expect(() =>
|
||||
FileConfigSource.create({
|
||||
path: 'a.yaml',
|
||||
}),
|
||||
).toThrow('Config load path is not absolute: "a.yaml"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 chokidar, { FSWatcher } from 'chokidar';
|
||||
import fs from 'fs-extra';
|
||||
import { basename, dirname, isAbsolute, resolve as resolvePath } from 'path';
|
||||
import yaml from 'yaml';
|
||||
import {
|
||||
AsyncConfigSourceIterator,
|
||||
ConfigSource,
|
||||
ConfigSourceData,
|
||||
SubstitutionFunc,
|
||||
ReadConfigDataOptions,
|
||||
} from './types';
|
||||
import { createConfigTransformer } from './transform';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* Options for {@link FileConfigSource.create}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FileConfigSourceOptions {
|
||||
/**
|
||||
* The path to the config file that should be loaded.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* A substitution function to use instead of the default environment substitution.
|
||||
*/
|
||||
substitutionFunc?: SubstitutionFunc;
|
||||
}
|
||||
|
||||
async function readFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.readFile(path, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A config source that loads configuration from a local file.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class FileConfigSource implements ConfigSource {
|
||||
/**
|
||||
* Creates a new config source that loads configuration from the given path.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The source will watch the file for changes, as well as any referenced files.
|
||||
*
|
||||
* @param options - Options for the config source.
|
||||
* @returns A new config source that loads from the given path.
|
||||
*/
|
||||
static create(options: FileConfigSourceOptions): ConfigSource {
|
||||
if (!isAbsolute(options.path)) {
|
||||
throw new Error(`Config load path is not absolute: "${options.path}"`);
|
||||
}
|
||||
return new FileConfigSource(options);
|
||||
}
|
||||
|
||||
readonly #path: string;
|
||||
readonly #substitutionFunc?: SubstitutionFunc;
|
||||
|
||||
private constructor(options: FileConfigSourceOptions) {
|
||||
this.#path = options.path;
|
||||
this.#substitutionFunc = options.substitutionFunc;
|
||||
}
|
||||
|
||||
// Work is duplicated across each read, in practice that should not
|
||||
// have any impact since there won't be multiple consumers. If that
|
||||
// changes it might be worth refactoring this to avoid duplicate work.
|
||||
async *readConfigData(
|
||||
options?: ReadConfigDataOptions,
|
||||
): AsyncConfigSourceIterator {
|
||||
const signal = options?.signal;
|
||||
const configFileName = basename(this.#path);
|
||||
|
||||
// Keep track of watched paths, since this is simpler than resetting the watcher
|
||||
const watchedPaths = new Array<string>();
|
||||
const watcher = chokidar.watch(this.#path, {
|
||||
usePolling: process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
const dir = dirname(this.#path);
|
||||
const transformer = createConfigTransformer({
|
||||
substitutionFunc: this.#substitutionFunc,
|
||||
readFile: async path => {
|
||||
const fullPath = resolvePath(dir, path);
|
||||
// Any files discovered while reading this config should be watched too
|
||||
watcher.add(fullPath);
|
||||
watchedPaths.push(fullPath);
|
||||
|
||||
const data = await readFile(fullPath);
|
||||
if (data === undefined) {
|
||||
throw new NotFoundError(
|
||||
`failed to include "${fullPath}", file does not exist`,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// This is the entry point for reading the file, called initially and on change
|
||||
const readConfigFile = async (): Promise<ConfigSourceData[]> => {
|
||||
// We clear the watched files every time we initiate a new read
|
||||
watcher.unwatch(watchedPaths);
|
||||
watchedPaths.length = 0;
|
||||
|
||||
watcher.add(this.#path);
|
||||
watchedPaths.push(this.#path);
|
||||
const content = await readFile(this.#path);
|
||||
if (content === undefined) {
|
||||
throw new NotFoundError(`Config file "${this.#path}" does not exist`);
|
||||
}
|
||||
const parsed = yaml.parse(content);
|
||||
if (parsed === null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const data = await transformer(parsed, { dir });
|
||||
return [{ data, context: configFileName, path: this.#path }];
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read config file at "${this.#path}", ${error.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
watcher.close();
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
|
||||
yield { configs: await readConfigFile() };
|
||||
|
||||
for (;;) {
|
||||
const event = await this.#waitForEvent(watcher, signal);
|
||||
if (event === 'abort') {
|
||||
return;
|
||||
}
|
||||
yield { configs: await readConfigFile() };
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `FileConfigSource{path="${this.#path}"}`;
|
||||
}
|
||||
|
||||
#waitForEvent(
|
||||
watcher: FSWatcher,
|
||||
signal?: AbortSignal,
|
||||
): Promise<'change' | 'abort'> {
|
||||
return new Promise(resolve => {
|
||||
function onChange() {
|
||||
resolve('change');
|
||||
onDone();
|
||||
}
|
||||
function onAbort() {
|
||||
resolve('abort');
|
||||
onDone();
|
||||
}
|
||||
function onDone() {
|
||||
watcher.removeListener('change', onChange);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
watcher.addListener('change', onChange);
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { MergedConfigSource } from './MergedConfigSource';
|
||||
import { MutableConfigSource } from './MutableConfigSource';
|
||||
import { isResolved, readAll, simpleSource } from './__testUtils__/testUtils';
|
||||
import { ConfigSource } from './types';
|
||||
|
||||
describe('MergedConfigSource', () => {
|
||||
it('should forward from a single source', async () => {
|
||||
const source = simpleSource([{ a: 1 }, { a: 2 }, { a: 3 }]);
|
||||
const merged = MergedConfigSource.from([source]);
|
||||
await expect(readAll(merged)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'mock-source' }],
|
||||
[{ data: { a: 2 }, context: 'mock-source' }],
|
||||
[{ data: { a: 3 }, context: 'mock-source' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should forward from multiple sources', async () => {
|
||||
const sourceA = simpleSource([{ a: 1 }, { a: 2 }, { a: 3 }], 'a');
|
||||
const sourceB = simpleSource([{ b: 1 }, { b: 2 }], 'b');
|
||||
const sourceC = simpleSource([{ c: 1 }], 'c');
|
||||
const merged = MergedConfigSource.from([sourceA, sourceB, sourceC]);
|
||||
await expect(readAll(merged)).resolves.toEqual([
|
||||
[
|
||||
{ data: { a: 1 }, context: 'a' },
|
||||
{ data: { b: 1 }, context: 'b' },
|
||||
{ data: { c: 1 }, context: 'c' },
|
||||
],
|
||||
[
|
||||
{ data: { a: 2 }, context: 'a' },
|
||||
{ data: { b: 1 }, context: 'b' },
|
||||
{ data: { c: 1 }, context: 'c' },
|
||||
],
|
||||
[
|
||||
{ data: { a: 3 }, context: 'a' },
|
||||
{ data: { b: 1 }, context: 'b' },
|
||||
{ data: { c: 1 }, context: 'c' },
|
||||
],
|
||||
[
|
||||
{ data: { a: 3 }, context: 'a' },
|
||||
{ data: { b: 2 }, context: 'b' },
|
||||
{ data: { c: 1 }, context: 'c' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should forward from multiple sources at difference pace', async () => {
|
||||
const sourceA = MutableConfigSource.create({ context: 'a' });
|
||||
const sourceB = MutableConfigSource.create({ context: 'b' });
|
||||
const merged = MergedConfigSource.from([sourceA, sourceB]);
|
||||
|
||||
const it = merged.readConfigData();
|
||||
|
||||
const first = it.next();
|
||||
await expect(isResolved(first, { wait: true })).resolves.toBe(false);
|
||||
sourceA.setData({ a: 1 });
|
||||
await expect(isResolved(first, { wait: true })).resolves.toBe(false);
|
||||
sourceB.setData({ b: 1 });
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{ data: { a: 1 }, context: 'a' },
|
||||
{ data: { b: 1 }, context: 'b' },
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
|
||||
sourceB.setData({ b: 2 });
|
||||
|
||||
await expect(it.next()).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{ data: { a: 1 }, context: 'a' },
|
||||
{ data: { b: 2 }, context: 'b' },
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
|
||||
const last = it.next();
|
||||
await expect(isResolved(last, { wait: true })).resolves.toBe(false);
|
||||
sourceA.close();
|
||||
await expect(isResolved(last, { wait: true })).resolves.toBe(false);
|
||||
sourceB.close();
|
||||
await expect(isResolved(last, { wait: true })).resolves.toBe(true);
|
||||
|
||||
await expect(last).resolves.toEqual({
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be flattened', async () => {
|
||||
const sym = Symbol.for(
|
||||
'@backstage/config-loader#MergedConfigSource.sources',
|
||||
);
|
||||
const sourceA: ConfigSource = {
|
||||
async *readConfigData() {
|
||||
yield { configs: [] };
|
||||
},
|
||||
};
|
||||
const sourceD: ConfigSource = {
|
||||
async *readConfigData() {
|
||||
yield { configs: [] };
|
||||
},
|
||||
};
|
||||
const sourceB: ConfigSource = {
|
||||
async *readConfigData() {
|
||||
yield { configs: [] };
|
||||
},
|
||||
};
|
||||
const sourceC: ConfigSource = {
|
||||
async *readConfigData() {
|
||||
yield { configs: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const sourceAB = MergedConfigSource.from([sourceA, sourceB]);
|
||||
const sourceABC = MergedConfigSource.from([sourceAB, sourceC]);
|
||||
const sourceABCD = MergedConfigSource.from([sourceABC, sourceD]);
|
||||
|
||||
expect((sourceAB as any)[sym]).toEqual([sourceA, sourceB]);
|
||||
expect((sourceABC as any)[sym]).toEqual([sourceA, sourceB, sourceC]);
|
||||
expect((sourceABCD as any)[sym]).toEqual([
|
||||
sourceA,
|
||||
sourceB,
|
||||
sourceC,
|
||||
sourceD,
|
||||
]);
|
||||
|
||||
await expect(readAll(sourceAB)).resolves.toEqual([[]]);
|
||||
await expect(readAll(sourceABC)).resolves.toEqual([[]]);
|
||||
await expect(readAll(sourceABCD)).resolves.toEqual([[]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
AsyncConfigSourceIterator,
|
||||
ConfigSource,
|
||||
ConfigSourceData,
|
||||
ReadConfigDataOptions,
|
||||
} from './types';
|
||||
|
||||
const sourcesSymbol = Symbol.for(
|
||||
'@backstage/config-loader#MergedConfigSource.sources',
|
||||
);
|
||||
|
||||
/** @internal */
|
||||
export class MergedConfigSource implements ConfigSource {
|
||||
// An optimization to flatten nested merged sources to avid unnecessary microtasks
|
||||
static #flattenSources(sources: ConfigSource[]): ConfigSource[] {
|
||||
return sources.flatMap(source => {
|
||||
if (
|
||||
sourcesSymbol in source &&
|
||||
Array.isArray((source as any)[sourcesSymbol])
|
||||
) {
|
||||
return this.#flattenSources(
|
||||
(source as any)[sourcesSymbol] as ConfigSource[],
|
||||
);
|
||||
}
|
||||
return source;
|
||||
});
|
||||
}
|
||||
|
||||
static from(sources: ConfigSource[]): ConfigSource {
|
||||
return new MergedConfigSource(this.#flattenSources(sources));
|
||||
}
|
||||
|
||||
[sourcesSymbol]: ConfigSource[];
|
||||
|
||||
private constructor(private readonly sources: ConfigSource[]) {
|
||||
this[sourcesSymbol] = this.sources;
|
||||
}
|
||||
|
||||
async *readConfigData(
|
||||
options?: ReadConfigDataOptions,
|
||||
): AsyncConfigSourceIterator {
|
||||
const its = this.sources.map(source => source.readConfigData(options));
|
||||
const initialResults = await Promise.all(its.map(it => it.next()));
|
||||
const configs = initialResults.map((result, i) => {
|
||||
if (result.done) {
|
||||
throw new Error(
|
||||
`Config source ${String(this.sources[i])} returned no data`,
|
||||
);
|
||||
}
|
||||
return result.value.configs;
|
||||
});
|
||||
|
||||
yield { configs: configs.flat(1) };
|
||||
|
||||
const results: Array<
|
||||
| Promise<
|
||||
readonly [
|
||||
number,
|
||||
IteratorResult<{ configs: ConfigSourceData[] }, void>,
|
||||
]
|
||||
>
|
||||
| undefined
|
||||
> = its.map((it, i) => nextWithIndex(it, i));
|
||||
|
||||
while (results.some(Boolean)) {
|
||||
try {
|
||||
const [i, result] = (await Promise.race(results.filter(Boolean)))!;
|
||||
if (result.done) {
|
||||
results[i] = undefined;
|
||||
} else {
|
||||
results[i] = nextWithIndex(its[i], i);
|
||||
configs[i] = result.value.configs;
|
||||
yield { configs: configs.flat(1) };
|
||||
}
|
||||
} catch (error) {
|
||||
const source = this.sources[error.index];
|
||||
if (source) {
|
||||
throw new Error(`Config source ${String(source)} failed: ${error}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `MergedConfigSource{${this.sources.map(String).join(', ')}}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to wait for the next value of the iterator, while decorating the value
|
||||
// or error with the index of the iterator.
|
||||
function nextWithIndex<T>(
|
||||
iterator: AsyncIterator<T, void, void>,
|
||||
index: number,
|
||||
): Promise<readonly [index: number, result: IteratorResult<T, void>]> {
|
||||
return iterator.next().then(
|
||||
r => [index, r] as const,
|
||||
e => {
|
||||
throw Object.assign(e, { index });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { ConfigSources } from './ConfigSources';
|
||||
import { MutableConfigSource } from './MutableConfigSource';
|
||||
import { isResolved, readAll } from './__testUtils__/testUtils';
|
||||
|
||||
describe('MutableConfigSource', () => {
|
||||
it('should be initialized with data', async () => {
|
||||
const source = MutableConfigSource.create({ data: { a: 1 } });
|
||||
const config = await ConfigSources.toConfig(source);
|
||||
expect(config.getNumber('a')).toEqual(1);
|
||||
config.close();
|
||||
});
|
||||
|
||||
it('should be created without data', async () => {
|
||||
const source = MutableConfigSource.create();
|
||||
const it = source.readConfigData();
|
||||
const first = it.next();
|
||||
await expect(isResolved(first)).resolves.toBe(false);
|
||||
source.setData({ a: 1 });
|
||||
await expect(first).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{
|
||||
data: { a: 1 },
|
||||
context: 'mutable-config',
|
||||
},
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be mutable and work with multiple consumers', async () => {
|
||||
const source = MutableConfigSource.create({ data: { a: 1 } });
|
||||
const resultsPromise = readAll(source);
|
||||
|
||||
const it = source.readConfigData();
|
||||
await expect(it.next()).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{
|
||||
data: { a: 1 },
|
||||
context: 'mutable-config',
|
||||
},
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
|
||||
const next2 = it.next();
|
||||
source.setData({ a: 2 });
|
||||
await expect(next2).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{
|
||||
data: { a: 2 },
|
||||
context: 'mutable-config',
|
||||
},
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
|
||||
const next3 = it.next();
|
||||
source.setData({ a: 3 });
|
||||
await expect(next3).resolves.toEqual({
|
||||
value: {
|
||||
configs: [
|
||||
{
|
||||
data: { a: 3 },
|
||||
context: 'mutable-config',
|
||||
},
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
|
||||
const last = it.next();
|
||||
source.close();
|
||||
await expect(last).resolves.toEqual({
|
||||
done: true,
|
||||
});
|
||||
|
||||
await expect(resultsPromise).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'mutable-config' }],
|
||||
[{ data: { a: 2 }, context: 'mutable-config' }],
|
||||
[{ data: { a: 3 }, context: 'mutable-config' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be self-mutable', async () => {
|
||||
const source = MutableConfigSource.create({ data: { a: 1 } });
|
||||
const resultsPromise = readAll(source);
|
||||
|
||||
for await (const { configs } of source.readConfigData()) {
|
||||
const a = configs[0].data.a as number;
|
||||
if (a < 3) {
|
||||
source.setData({ a: a + 1 });
|
||||
} else {
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
await expect(resultsPromise).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'mutable-config' }],
|
||||
[{ data: { a: 2 }, context: 'mutable-config' }],
|
||||
[{ data: { a: 3 }, context: 'mutable-config' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
AsyncConfigSourceIterator,
|
||||
ConfigSource,
|
||||
ReadConfigDataOptions,
|
||||
} from './types';
|
||||
import { simpleDefer, SimpleDeferred, waitOrAbort } from './utils';
|
||||
|
||||
/**
|
||||
* Options for {@link MutableConfigSource.create}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MutableConfigSourceOptions {
|
||||
data?: JsonObject;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A config source that can be updated with new data.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MutableConfigSource implements ConfigSource {
|
||||
/**
|
||||
* Creates a new mutable config source.
|
||||
*
|
||||
* @param options - Options for the config source.
|
||||
* @returns A new mutable config source.
|
||||
*/
|
||||
static create(options?: MutableConfigSourceOptions): MutableConfigSource {
|
||||
return new MutableConfigSource(
|
||||
options?.context ?? 'mutable-config',
|
||||
options?.data,
|
||||
);
|
||||
}
|
||||
|
||||
#currentData?: JsonObject;
|
||||
#deferred: SimpleDeferred<void>;
|
||||
readonly #context: string;
|
||||
readonly #abortController = new AbortController();
|
||||
|
||||
private constructor(context: string, initialData?: JsonObject) {
|
||||
this.#currentData = initialData;
|
||||
this.#context = context;
|
||||
this.#deferred = simpleDefer();
|
||||
}
|
||||
|
||||
async *readConfigData(
|
||||
options?: ReadConfigDataOptions | undefined,
|
||||
): AsyncConfigSourceIterator {
|
||||
let deferredPromise = this.#deferred.promise;
|
||||
|
||||
if (this.#currentData !== undefined) {
|
||||
yield { configs: [{ data: this.#currentData, context: this.#context }] };
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const [ok] = await waitOrAbort(deferredPromise, [
|
||||
options?.signal,
|
||||
this.#abortController.signal,
|
||||
]);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
deferredPromise = this.#deferred.promise;
|
||||
|
||||
if (this.#currentData !== undefined) {
|
||||
yield {
|
||||
configs: [{ data: this.#currentData, context: this.#context }],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the data of the config source.
|
||||
*
|
||||
* @param data - The new data to set
|
||||
*/
|
||||
setData(data: JsonObject): void {
|
||||
if (!this.#abortController.signal.aborted) {
|
||||
this.#currentData = data;
|
||||
const oldDeferred = this.#deferred;
|
||||
this.#deferred = simpleDefer();
|
||||
oldDeferred.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the config source, preventing any further updates.
|
||||
*/
|
||||
close(): void {
|
||||
this.#currentData = undefined;
|
||||
this.#abortController.abort();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `MutableConfigSource{}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { ObservableConfigProxy } from './ObservableConfigProxy';
|
||||
|
||||
describe('ObservableConfigProxy', () => {
|
||||
it('should notify subscribers', () => {
|
||||
const config = ObservableConfigProxy.create(new AbortController());
|
||||
|
||||
const fn = jest.fn();
|
||||
const sub = config.subscribe(fn);
|
||||
expect(config.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config.setConfig(new ConfigReader({}));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(config.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 1 }));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(config.getOptionalNumber('x')).toBe(1);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 3 }));
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
sub.unsubscribe();
|
||||
expect(config.getOptionalNumber('x')).toBe(3);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 5 }));
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(config.getOptionalNumber('x')).toBe(5);
|
||||
});
|
||||
|
||||
it('should forward subscriptions', () => {
|
||||
const config1 = ObservableConfigProxy.create(new AbortController());
|
||||
|
||||
const fn1 = jest.fn();
|
||||
const fn2 = jest.fn();
|
||||
const fn3 = jest.fn();
|
||||
const config2 = config1.getConfig('a');
|
||||
const config3 = config2.getConfig('b');
|
||||
const sub1 = config1.subscribe(fn1);
|
||||
const sub2 = config2.subscribe!(fn2);
|
||||
const sub3 = config3.subscribe!(fn3);
|
||||
expect(config1.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config2.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config3.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config1.setConfig(new ConfigReader({}));
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
expect(fn3).toHaveBeenCalledTimes(1);
|
||||
expect(config1.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config2.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config3.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config1.setConfig(new ConfigReader({ x: 1, a: { x: 2, b: { x: 3 } } }));
|
||||
expect(fn1).toHaveBeenCalledTimes(2);
|
||||
expect(fn2).toHaveBeenCalledTimes(2);
|
||||
expect(fn3).toHaveBeenCalledTimes(2);
|
||||
expect(config1.getNumber('x')).toBe(1);
|
||||
expect(config2.getNumber('x')).toBe(2);
|
||||
expect(config3.getNumber('x')).toBe(3);
|
||||
|
||||
sub1.unsubscribe();
|
||||
sub2.unsubscribe();
|
||||
sub3.unsubscribe();
|
||||
|
||||
config1.setConfig(new ConfigReader({ x: 4, a: { x: 5, b: { x: 6 } } }));
|
||||
expect(fn1).toHaveBeenCalledTimes(2);
|
||||
expect(fn2).toHaveBeenCalledTimes(2);
|
||||
expect(fn3).toHaveBeenCalledTimes(2);
|
||||
expect(config1.getNumber('x')).toBe(4);
|
||||
expect(config2.getNumber('x')).toBe(5);
|
||||
expect(config3.getNumber('x')).toBe(6);
|
||||
|
||||
config1.setConfig(new ConfigReader({}));
|
||||
expect(() => config1.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'x'",
|
||||
);
|
||||
expect(() => config2.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'a'",
|
||||
);
|
||||
expect(() => config3.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'a'",
|
||||
);
|
||||
|
||||
config1.setConfig(
|
||||
new ConfigReader({ x: 's', a: { x: 's', b: { x: 's' } } }),
|
||||
);
|
||||
expect(() => config1.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'x' in 'mock-config' to a number",
|
||||
);
|
||||
expect(() => config2.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'a.x' in 'mock-config' to a number",
|
||||
);
|
||||
expect(() => config3.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'a.b.x' in 'mock-config' to a number",
|
||||
);
|
||||
});
|
||||
|
||||
it('should make sub configs available as expected', () => {
|
||||
const config = ObservableConfigProxy.create(new AbortController());
|
||||
|
||||
config.setConfig(new ConfigReader({ a: { x: 1 } }));
|
||||
|
||||
expect(config.getConfig('a')).toBeDefined();
|
||||
expect(config.getConfig('a').getNumber('x')).toBe(1);
|
||||
expect(config.getConfig('a').getOptionalNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('a')?.getNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('a')?.getOptionalNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('b')).toBeUndefined();
|
||||
expect(() => config.getConfig('b')).toBeDefined();
|
||||
expect(() => config.getConfig('b').get()).toThrow();
|
||||
});
|
||||
|
||||
it('should be closed', () => {
|
||||
const controller = new AbortController();
|
||||
const config = ObservableConfigProxy.create(controller);
|
||||
config.setConfig(new ConfigReader({ a: { x: 1 } }));
|
||||
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
config.close();
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
config.close();
|
||||
|
||||
expect(() => (config.getConfig('a') as any).close()).toThrow(
|
||||
'Only the root config can be closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { Config, ConfigReader } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export class ObservableConfigProxy implements Config {
|
||||
private config: Config = new ConfigReader({});
|
||||
|
||||
private readonly subscribers: (() => void)[] = [];
|
||||
|
||||
static create(abortController: AbortController): ObservableConfigProxy {
|
||||
return new ObservableConfigProxy(undefined, undefined, abortController);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly parent?: ObservableConfigProxy,
|
||||
private readonly parentKey?: string,
|
||||
private readonly abortController?: AbortController,
|
||||
) {
|
||||
if (parent && !parentKey) {
|
||||
throw new Error('parentKey is required if parent is set');
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(config: Config) {
|
||||
if (this.parent) {
|
||||
throw new Error('immutable');
|
||||
}
|
||||
this.config = config;
|
||||
for (const subscriber of this.subscribers) {
|
||||
try {
|
||||
subscriber();
|
||||
} catch (error) {
|
||||
console.error(`Config subscriber threw error, ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.abortController) {
|
||||
throw new Error('Only the root config can be closed');
|
||||
}
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
subscribe(onChange: () => void): { unsubscribe: () => void } {
|
||||
if (this.parent) {
|
||||
return this.parent.subscribe(onChange);
|
||||
}
|
||||
|
||||
this.subscribers.push(onChange);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
const index = this.subscribers.indexOf(onChange);
|
||||
if (index >= 0) {
|
||||
this.subscribers.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private select(required: true): Config;
|
||||
private select(required: false): Config | undefined;
|
||||
private select(required: boolean): Config | undefined {
|
||||
if (this.parent && this.parentKey) {
|
||||
if (required) {
|
||||
return this.parent.select(true).getConfig(this.parentKey);
|
||||
}
|
||||
return this.parent.select(false)?.getOptionalConfig(this.parentKey);
|
||||
}
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.select(false)?.has(key) ?? false;
|
||||
}
|
||||
keys(): string[] {
|
||||
return this.select(false)?.keys() ?? [];
|
||||
}
|
||||
get<T = JsonValue>(key?: string): T {
|
||||
return this.select(true).get(key);
|
||||
}
|
||||
getOptional<T = JsonValue>(key?: string): T | undefined {
|
||||
return this.select(false)?.getOptional(key);
|
||||
}
|
||||
getConfig(key: string): Config {
|
||||
return new ObservableConfigProxy(this, key);
|
||||
}
|
||||
getOptionalConfig(key: string): Config | undefined {
|
||||
if (this.select(false)?.has(key)) {
|
||||
return new ObservableConfigProxy(this, key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
getConfigArray(key: string): Config[] {
|
||||
return this.select(true).getConfigArray(key);
|
||||
}
|
||||
getOptionalConfigArray(key: string): Config[] | undefined {
|
||||
return this.select(false)?.getOptionalConfigArray(key);
|
||||
}
|
||||
getNumber(key: string): number {
|
||||
return this.select(true).getNumber(key);
|
||||
}
|
||||
getOptionalNumber(key: string): number | undefined {
|
||||
return this.select(false)?.getOptionalNumber(key);
|
||||
}
|
||||
getBoolean(key: string): boolean {
|
||||
return this.select(true).getBoolean(key);
|
||||
}
|
||||
getOptionalBoolean(key: string): boolean | undefined {
|
||||
return this.select(false)?.getOptionalBoolean(key);
|
||||
}
|
||||
getString(key: string): string {
|
||||
return this.select(true).getString(key);
|
||||
}
|
||||
getOptionalString(key: string): string | undefined {
|
||||
return this.select(false)?.getOptionalString(key);
|
||||
}
|
||||
getStringArray(key: string): string[] {
|
||||
return this.select(true).getStringArray(key);
|
||||
}
|
||||
getOptionalStringArray(key: string): string[] | undefined {
|
||||
return this.select(false)?.getOptionalStringArray(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { rest } from 'msw';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { RemoteConfigSource } from './RemoteConfigSource';
|
||||
import { readN } from './__testUtils__/testUtils';
|
||||
|
||||
describe('RemoteConfigSource', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
it('should load config from a remote URL', async () => {
|
||||
worker.use(
|
||||
rest.get('http://localhost/config.yaml', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.body(`
|
||||
app:
|
||||
title: Example App
|
||||
substituted: \${VALUE}
|
||||
escaped: \$\${VALUE}
|
||||
`),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const source = RemoteConfigSource.create({
|
||||
url: 'http://localhost/config.yaml',
|
||||
substitutionFunc: async () => 'x',
|
||||
});
|
||||
|
||||
await expect(readN(source, 1)).resolves.toEqual([
|
||||
[
|
||||
{
|
||||
context: 'http://localhost/config.yaml',
|
||||
data: {
|
||||
app: {
|
||||
title: 'Example App',
|
||||
substituted: 'x',
|
||||
escaped: '${VALUE}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reload config from a remote URL', async () => {
|
||||
let fetched = false;
|
||||
|
||||
worker.use(
|
||||
rest.get('http://localhost/config.yaml', (_req, res, ctx) => {
|
||||
if (!fetched) {
|
||||
fetched = true;
|
||||
return res(ctx.body('x: 1'));
|
||||
}
|
||||
return res(ctx.body('x: 2'));
|
||||
}),
|
||||
);
|
||||
|
||||
const source = RemoteConfigSource.create({
|
||||
url: 'http://localhost/config.yaml',
|
||||
reloadInterval: { seconds: 0 },
|
||||
});
|
||||
|
||||
await expect(readN(source, 2)).resolves.toEqual([
|
||||
[{ context: 'http://localhost/config.yaml', data: { x: 1 } }],
|
||||
[{ context: 'http://localhost/config.yaml', data: { x: 2 } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { ResponseError } from '@backstage/errors';
|
||||
import { HumanDuration, JsonObject } from '@backstage/types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import fetch from 'node-fetch';
|
||||
import yaml from 'yaml';
|
||||
import { ConfigTransformer, createConfigTransformer } from './transform';
|
||||
import {
|
||||
AsyncConfigSourceIterator,
|
||||
ConfigSource,
|
||||
SubstitutionFunc,
|
||||
ReadConfigDataOptions,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_RELOAD_INTERVAL = { seconds: 60 };
|
||||
|
||||
function durationToMs(duration: HumanDuration): number {
|
||||
const {
|
||||
years = 0,
|
||||
months = 0,
|
||||
weeks = 0,
|
||||
days = 0,
|
||||
hours = 0,
|
||||
minutes = 0,
|
||||
seconds = 0,
|
||||
milliseconds = 0,
|
||||
} = duration;
|
||||
|
||||
const totalDays = years * 365 + months * 30 + weeks * 7 + days;
|
||||
const totalHours = totalDays * 24 + hours;
|
||||
const totalMinutes = totalHours * 60 + minutes;
|
||||
const totalSeconds = totalMinutes * 60 + seconds;
|
||||
const totalMilliseconds = totalSeconds * 1000 + milliseconds;
|
||||
|
||||
return totalMilliseconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link RemoteConfigSource.create}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RemoteConfigSourceOptions {
|
||||
/**
|
||||
* The URL to load the config from.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* How often to reload the config from the remote URL, defaults to 1 minute.
|
||||
*
|
||||
* Set to Infinity to disable reloading, for example `{ days: Infinity }`.
|
||||
*/
|
||||
reloadInterval?: HumanDuration;
|
||||
|
||||
/**
|
||||
* A substitution function to use instead of the default environment substitution.
|
||||
*/
|
||||
substitutionFunc?: SubstitutionFunc;
|
||||
}
|
||||
|
||||
/**
|
||||
* A config source that loads configuration from a remote URL.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class RemoteConfigSource implements ConfigSource {
|
||||
/**
|
||||
* Creates a new {@link RemoteConfigSource}.
|
||||
*
|
||||
* @param options - Options for the source.
|
||||
* @returns A new remote config source.
|
||||
*/
|
||||
static create(options: RemoteConfigSourceOptions): ConfigSource {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(options.url);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid URL provided to remote config source, '${options.url}', ${error}`,
|
||||
);
|
||||
}
|
||||
return new RemoteConfigSource(options);
|
||||
}
|
||||
|
||||
readonly #url: string;
|
||||
readonly #reloadIntervalMs: number;
|
||||
readonly #transformer: ConfigTransformer;
|
||||
|
||||
private constructor(options: RemoteConfigSourceOptions) {
|
||||
this.#url = options.url;
|
||||
this.#reloadIntervalMs = durationToMs(
|
||||
options.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
|
||||
);
|
||||
this.#transformer = createConfigTransformer({
|
||||
substitutionFunc: options.substitutionFunc,
|
||||
});
|
||||
}
|
||||
|
||||
async *readConfigData(
|
||||
options?: ReadConfigDataOptions | undefined,
|
||||
): AsyncConfigSourceIterator {
|
||||
let data = await this.#load();
|
||||
|
||||
yield { configs: [{ data, context: this.#url }] };
|
||||
|
||||
for (;;) {
|
||||
await this.#wait(options?.signal);
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newData = await this.#load(options?.signal);
|
||||
if (newData && !isEqual(data, newData)) {
|
||||
data = newData;
|
||||
yield { configs: [{ data, context: this.#url }] };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(`Failed to read config from ${this.#url}, ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `RemoteConfigSource{path="${this.#url}"}`;
|
||||
}
|
||||
|
||||
async #load(signal?: AbortSignal): Promise<JsonObject> {
|
||||
const res = await fetch(this.#url, {
|
||||
signal: signal as import('node-fetch').RequestInit['signal'],
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw ResponseError.fromResponse(res);
|
||||
}
|
||||
|
||||
const content = await res.text();
|
||||
const data = await this.#transformer(yaml.parse(content));
|
||||
if (data === null) {
|
||||
throw new Error('configuration data is null');
|
||||
} else if (typeof data !== 'object') {
|
||||
throw new Error('configuration data is not an object');
|
||||
} else if (Array.isArray(data)) {
|
||||
throw new Error(
|
||||
'configuration data is an array, expected an object instead',
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async #wait(signal?: AbortSignal) {
|
||||
return new Promise<void>(resolve => {
|
||||
const timeoutId = setTimeout(onDone, this.#reloadIntervalMs);
|
||||
signal?.addEventListener('abort', onDone);
|
||||
|
||||
function onDone() {
|
||||
clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onDone);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { JsonObject } from '@backstage/types';
|
||||
import { StaticConfigSource } from './StaticConfigSource';
|
||||
import { readAll } from './__testUtils__/testUtils';
|
||||
import ZenObservable from 'zen-observable';
|
||||
|
||||
describe('StaticConfigSource', () => {
|
||||
it('should be created from data', async () => {
|
||||
const source = StaticConfigSource.create({ data: { a: 1 } });
|
||||
await expect(readAll(source)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'static-config' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be created from promise', async () => {
|
||||
const source = StaticConfigSource.create({
|
||||
data: Promise.resolve({ a: 1 }),
|
||||
});
|
||||
await expect(readAll(source)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'static-config' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be created from observable', async () => {
|
||||
const source = StaticConfigSource.create({
|
||||
data: ZenObservable.of<JsonObject>({ a: 1 }, { a: 2 }, { a: 3 }),
|
||||
});
|
||||
await expect(readAll(source)).resolves.toEqual([
|
||||
[{ data: { a: 1 }, context: 'static-config' }],
|
||||
[{ data: { a: 2 }, context: 'static-config' }],
|
||||
[{ data: { a: 3 }, context: 'static-config' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { JsonObject, Observable } from '@backstage/types';
|
||||
import {
|
||||
AsyncConfigSourceIterator,
|
||||
ConfigSource,
|
||||
ReadConfigDataOptions,
|
||||
} from './types';
|
||||
import { simpleDefer } from './utils';
|
||||
|
||||
/**
|
||||
* Options for {@link StaticConfigSource.create}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface StaticConfigSourceOptions {
|
||||
data:
|
||||
| JsonObject
|
||||
| Observable<JsonObject>
|
||||
| PromiseLike<JsonObject>
|
||||
| AsyncIterable<JsonObject>;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
class StaticObservableConfigSource implements ConfigSource {
|
||||
constructor(
|
||||
private readonly data: Observable<JsonObject>,
|
||||
private readonly context: string,
|
||||
) {}
|
||||
|
||||
async *readConfigData(
|
||||
options?: ReadConfigDataOptions | undefined,
|
||||
): AsyncConfigSourceIterator {
|
||||
const queue = new Array<JsonObject>();
|
||||
let deferred = simpleDefer<void>();
|
||||
|
||||
const sub = this.data.subscribe({
|
||||
next(value) {
|
||||
queue.push(value);
|
||||
deferred.resolve();
|
||||
deferred = simpleDefer();
|
||||
},
|
||||
complete() {
|
||||
deferred.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const signal = options?.signal;
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
sub.unsubscribe();
|
||||
queue.length = 0;
|
||||
deferred.resolve();
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
await deferred.promise;
|
||||
if (queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
yield { configs: [{ data: queue.shift()!, context: this.context }] };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isObservable<T>(value: {}): value is Observable<T> {
|
||||
return 'subscribe' in value && typeof (value as any).subscribe === 'function';
|
||||
}
|
||||
|
||||
function isAsyncIterable<T>(value: {}): value is AsyncIterable<T> {
|
||||
return Symbol.asyncIterator in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A configuration source that reads from a static object, promise, iterable, or observable.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class StaticConfigSource implements ConfigSource {
|
||||
/**
|
||||
* Creates a new {@link StaticConfigSource}.
|
||||
*
|
||||
* @param options - Options for the config source
|
||||
* @returns A new static config source
|
||||
*/
|
||||
static create(options: StaticConfigSourceOptions): ConfigSource {
|
||||
const { data, context = 'static-config' } = options;
|
||||
if (!data) {
|
||||
return {
|
||||
async *readConfigData(): AsyncConfigSourceIterator {
|
||||
yield { configs: [] };
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isObservable<JsonObject>(data)) {
|
||||
return new StaticObservableConfigSource(data, context);
|
||||
}
|
||||
|
||||
if (isAsyncIterable(data)) {
|
||||
return {
|
||||
async *readConfigData(): AsyncConfigSourceIterator {
|
||||
for await (const value of data) {
|
||||
yield { configs: [{ data: value, context }] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return new StaticConfigSource(data, context);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly promise: JsonObject | PromiseLike<JsonObject>,
|
||||
private readonly context: string,
|
||||
) {}
|
||||
|
||||
async *readConfigData(): AsyncConfigSourceIterator {
|
||||
yield { configs: [{ data: await this.promise, context: this.context }] };
|
||||
return;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `StaticConfigSource{}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
a: 1
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { JsonObject } from '@backstage/types';
|
||||
import { ConfigSource, ConfigSourceData } from '../types';
|
||||
|
||||
export function isResolved(
|
||||
promise: Promise<unknown>,
|
||||
{ wait }: { wait?: number | boolean } = {},
|
||||
): Promise<boolean> {
|
||||
return Promise.race([
|
||||
promise.then(() => true),
|
||||
typeof wait !== 'undefined'
|
||||
? new Promise<boolean>(resolve =>
|
||||
setTimeout(
|
||||
() => resolve(false),
|
||||
typeof wait === 'number' ? wait : 10,
|
||||
),
|
||||
)
|
||||
: Promise.resolve().then(() => false),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function readAll(
|
||||
source: ConfigSource,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ConfigSourceData[][]> {
|
||||
const results: ConfigSourceData[][] = [];
|
||||
|
||||
try {
|
||||
for await (const { configs } of source.readConfigData({ signal })) {
|
||||
results.push(configs);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function readN(
|
||||
source: ConfigSource,
|
||||
n: number,
|
||||
): Promise<ConfigSourceData[][]> {
|
||||
const results: ConfigSourceData[][] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
results.push(configs);
|
||||
if (results.length >= n) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
controller.abort();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function simpleSource(
|
||||
data: JsonObject[],
|
||||
context: string = 'mock-source',
|
||||
): ConfigSource {
|
||||
return {
|
||||
async *readConfigData() {
|
||||
for (const d of data) {
|
||||
yield { configs: [{ data: d, context }] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ConfigSources } from './ConfigSources';
|
||||
export type {
|
||||
BaseConfigSourcesOptions,
|
||||
ClosableConfig,
|
||||
ConfigSourceTarget,
|
||||
ConfigSourcesDefaultForTargetsOptions,
|
||||
ConfigSourcesDefaultOptions,
|
||||
} from './ConfigSources';
|
||||
export { EnvConfigSource, readEnvConfig } from './EnvConfigSource';
|
||||
export type { EnvConfigSourceOptions } from './EnvConfigSource';
|
||||
export { FileConfigSource } from './FileConfigSource';
|
||||
export type { FileConfigSourceOptions } from './FileConfigSource';
|
||||
export { MutableConfigSource } from './MutableConfigSource';
|
||||
export type { MutableConfigSourceOptions } from './MutableConfigSource';
|
||||
export { RemoteConfigSource } from './RemoteConfigSource';
|
||||
export type { RemoteConfigSourceOptions } from './RemoteConfigSource';
|
||||
export { StaticConfigSource } from './StaticConfigSource';
|
||||
export type { StaticConfigSourceOptions } from './StaticConfigSource';
|
||||
export type {
|
||||
SubstitutionFunc as EnvFunc,
|
||||
ConfigSource,
|
||||
ConfigSourceData,
|
||||
ReadConfigDataOptions,
|
||||
AsyncConfigSourceIterator,
|
||||
} from './types';
|
||||
+3
-3
@@ -19,7 +19,6 @@ import { applyConfigTransforms } from './apply';
|
||||
describe('applyConfigTransforms', () => {
|
||||
it('should apply not transforms to input', async () => {
|
||||
const data = applyConfigTransforms(
|
||||
'',
|
||||
{
|
||||
app: {
|
||||
title: 'Test',
|
||||
@@ -28,6 +27,7 @@ describe('applyConfigTransforms', () => {
|
||||
z: null,
|
||||
},
|
||||
},
|
||||
{},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -41,14 +41,13 @@ describe('applyConfigTransforms', () => {
|
||||
});
|
||||
|
||||
it('should throw if input is not an object', async () => {
|
||||
const config = applyConfigTransforms('', 'not-config', []);
|
||||
const config = applyConfigTransforms('not-config', {}, []);
|
||||
|
||||
await expect(config).rejects.toThrow('expected object at config root');
|
||||
});
|
||||
|
||||
it('should apply transforms', async () => {
|
||||
const config = applyConfigTransforms(
|
||||
'',
|
||||
{
|
||||
app: {
|
||||
title: 'Test',
|
||||
@@ -57,6 +56,7 @@ describe('applyConfigTransforms', () => {
|
||||
z: null,
|
||||
},
|
||||
},
|
||||
{},
|
||||
[
|
||||
async value => {
|
||||
if (typeof value === 'number') {
|
||||
+37
-6
@@ -16,34 +16,37 @@
|
||||
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { TransformFunc } from './types';
|
||||
import { TransformContext, TransformFunc } from './types';
|
||||
import { isObject } from './utils';
|
||||
import { createSubstitutionTransform } from './substitution';
|
||||
import { createIncludeTransform } from './include';
|
||||
import { SubstitutionFunc } from '../types';
|
||||
|
||||
/**
|
||||
* Applies a set of transforms to raw configuration data.
|
||||
*/
|
||||
export async function applyConfigTransforms(
|
||||
initialDir: string,
|
||||
input: JsonValue,
|
||||
context: { dir?: string },
|
||||
transforms: TransformFunc[],
|
||||
): Promise<JsonObject> {
|
||||
async function transform(
|
||||
inputObj: JsonValue,
|
||||
path: string,
|
||||
baseDir: string,
|
||||
baseDir?: string,
|
||||
): Promise<JsonValue | undefined> {
|
||||
let obj = inputObj;
|
||||
let dir = baseDir;
|
||||
|
||||
for (const tf of transforms) {
|
||||
try {
|
||||
const result = await tf(inputObj, baseDir);
|
||||
const result = await tf(inputObj, { dir });
|
||||
if (result.applied) {
|
||||
if (result.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
obj = result.value;
|
||||
dir = result.newBaseDir ?? dir;
|
||||
dir = result?.newDir ?? dir;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -84,9 +87,37 @@ export async function applyConfigTransforms(
|
||||
return out;
|
||||
}
|
||||
|
||||
const finalData = await transform(input, '', initialDir);
|
||||
const finalData = await transform(input, '', context?.dir);
|
||||
if (!isObject(finalData)) {
|
||||
throw new TypeError('expected object at config root');
|
||||
}
|
||||
return finalData;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type ConfigTransformer = (
|
||||
input: JsonObject,
|
||||
context?: TransformContext,
|
||||
) => Promise<JsonObject>;
|
||||
|
||||
/** @internal */
|
||||
export function createConfigTransformer(options: {
|
||||
substitutionFunc?: SubstitutionFunc;
|
||||
readFile?(path: string): Promise<string>;
|
||||
}): ConfigTransformer {
|
||||
const { substitutionFunc = async name => process.env[name], readFile } =
|
||||
options;
|
||||
const substitutionTransform = createSubstitutionTransform(substitutionFunc);
|
||||
const transforms = [substitutionTransform];
|
||||
if (readFile) {
|
||||
const includeTransform = createIncludeTransform(
|
||||
substitutionFunc,
|
||||
readFile,
|
||||
substitutionTransform,
|
||||
);
|
||||
transforms.push(includeTransform);
|
||||
}
|
||||
|
||||
return async (input, ctx) =>
|
||||
applyConfigTransforms(input, ctx ?? {}, transforms);
|
||||
}
|
||||
+36
-22
@@ -69,34 +69,34 @@ const includeTransform = createIncludeTransform(env, readFile, substitute);
|
||||
|
||||
describe('includeTransform', () => {
|
||||
it('should not transform unknown values', async () => {
|
||||
await expect(includeTransform('foo', root)).resolves.toEqual({
|
||||
await expect(includeTransform('foo', { dir: root })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(includeTransform([1], root)).resolves.toEqual({
|
||||
await expect(includeTransform([1], { dir: root })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(includeTransform(1, root)).resolves.toEqual({
|
||||
await expect(includeTransform(1, { dir: root })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(includeTransform({ x: 'y' }, root)).resolves.toEqual({
|
||||
await expect(includeTransform({ x: 'y' }, { dir: root })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(includeTransform(null, root)).resolves.toEqual({
|
||||
await expect(includeTransform(null, { dir: root })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include text files', async () => {
|
||||
await expect(
|
||||
includeTransform({ $file: 'my-secret' }, root),
|
||||
includeTransform({ $file: 'my-secret' }, { dir: root }),
|
||||
).resolves.toEqual({ applied: true, value: 'secret' });
|
||||
await expect(
|
||||
includeTransform({ $file: 'no-secret' }, root),
|
||||
includeTransform({ $file: 'no-secret' }, { dir: root }),
|
||||
).rejects.toThrow('File not found!');
|
||||
});
|
||||
it('should trim newlines from end of file', async () => {
|
||||
await expect(
|
||||
includeTransform({ $file: 'with-newline-at-the-end' }, root),
|
||||
includeTransform({ $file: 'with-newline-at-the-end' }, { dir: root }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: 'value without newline at the end',
|
||||
@@ -104,12 +104,14 @@ describe('includeTransform', () => {
|
||||
});
|
||||
|
||||
it('should include env vars', async () => {
|
||||
await expect(includeTransform({ $env: 'SECRET' }, root)).resolves.toEqual({
|
||||
await expect(
|
||||
includeTransform({ $env: 'SECRET' }, { dir: root }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: 'my-secret',
|
||||
});
|
||||
await expect(
|
||||
includeTransform({ $env: 'NO_SECRET' }, root),
|
||||
includeTransform({ $env: 'NO_SECRET' }, { dir: root }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: undefined,
|
||||
@@ -119,16 +121,19 @@ describe('includeTransform', () => {
|
||||
it('should include config files', async () => {
|
||||
// New format with path in fragment
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.json#a.b.c' }, root),
|
||||
includeTransform({ $include: 'my-data.json#a.b.c' }, { dir: root }),
|
||||
).resolves.toEqual({ applied: true, value: 42 });
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.json#a.b' }, root),
|
||||
includeTransform({ $include: 'my-data.json#a.b' }, { dir: root }),
|
||||
).resolves.toEqual({ applied: true, value: { c: 42 } });
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.yaml#some.yaml.key' }, root),
|
||||
includeTransform(
|
||||
{ $include: 'my-data.yaml#some.yaml.key' },
|
||||
{ dir: root },
|
||||
),
|
||||
).resolves.toEqual({ applied: true, value: 7 });
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.yaml' }, root),
|
||||
includeTransform({ $include: 'my-data.yaml' }, { dir: root }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: {
|
||||
@@ -136,7 +141,7 @@ describe('includeTransform', () => {
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.yaml#' }, root),
|
||||
includeTransform({ $include: 'my-data.yaml#' }, { dir: root }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: {
|
||||
@@ -144,26 +149,32 @@ describe('includeTransform', () => {
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.yml#different.key' }, root),
|
||||
includeTransform(
|
||||
{ $include: 'my-data.yml#different.key' },
|
||||
{ dir: root },
|
||||
),
|
||||
).resolves.toEqual({ applied: true, value: 'hello' });
|
||||
});
|
||||
|
||||
it('should reject invalid includes', async () => {
|
||||
await expect(
|
||||
includeTransform({ $include: 'no-parser.js' }, root),
|
||||
includeTransform({ $include: 'no-parser.js' }, { dir: root }),
|
||||
).rejects.toThrow(
|
||||
'no configuration parser available for included file no-parser.js',
|
||||
);
|
||||
await expect(
|
||||
includeTransform({ $include: 'no-data.yml#different.key' }, root),
|
||||
includeTransform(
|
||||
{ $include: 'no-data.yml#different.key' },
|
||||
{ dir: root },
|
||||
),
|
||||
).rejects.toThrow('File not found!');
|
||||
await expect(
|
||||
includeTransform({ $include: 'my-data.yml#missing.key' }, root),
|
||||
includeTransform({ $include: 'my-data.yml#missing.key' }, { dir: root }),
|
||||
).rejects.toThrow(
|
||||
"value at 'missing' in included file my-data.yml is not an object",
|
||||
);
|
||||
await expect(
|
||||
includeTransform({ $include: 'invalid.yaml' }, root),
|
||||
includeTransform({ $include: 'invalid.yaml' }, { dir: root }),
|
||||
).rejects.toThrow(
|
||||
/failed to parse included file invalid.yaml, YAMLParseError: Flow sequence in block collection must be sufficiently indented and end with a \] at line 1, column 7:\s+foo: \[\}/,
|
||||
);
|
||||
@@ -171,11 +182,14 @@ describe('includeTransform', () => {
|
||||
|
||||
it('should call substitute prior to handling includes directive', async () => {
|
||||
await expect(
|
||||
includeTransform({ $include: `${substituteMe}/my-data.json` }, root),
|
||||
includeTransform(
|
||||
{ $include: `${substituteMe}/my-data.json` },
|
||||
{ dir: root },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: { foo: 'bar' },
|
||||
newBaseDir: resolvePath(root, mySubstitution),
|
||||
newDir: resolvePath(root, mySubstitution),
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
-8
@@ -18,7 +18,8 @@ import yaml from 'yaml';
|
||||
import { extname, dirname, resolve as resolvePath } from 'path';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { isObject } from './utils';
|
||||
import { TransformFunc, EnvFunc, ReadFileFunc } from './types';
|
||||
import { TransformFunc, ReadFileFunc } from './types';
|
||||
import { SubstitutionFunc } from '../types';
|
||||
|
||||
// Parsers for each type of included file
|
||||
const includeFileParser: {
|
||||
@@ -33,11 +34,15 @@ const includeFileParser: {
|
||||
* Transforms a include description into the actual included value.
|
||||
*/
|
||||
export function createIncludeTransform(
|
||||
env: EnvFunc,
|
||||
env: SubstitutionFunc,
|
||||
readFile: ReadFileFunc,
|
||||
substitute: TransformFunc,
|
||||
): TransformFunc {
|
||||
return async (input: JsonValue, baseDir: string) => {
|
||||
return async (input, context) => {
|
||||
const { dir } = context;
|
||||
if (!dir) {
|
||||
throw new Error('Include transform requires a base directory');
|
||||
}
|
||||
if (!isObject(input)) {
|
||||
return { applied: false };
|
||||
}
|
||||
@@ -59,7 +64,7 @@ export function createIncludeTransform(
|
||||
throw new Error(`${includeKey} include value is not a string`);
|
||||
}
|
||||
|
||||
const substituteResults = await substitute(rawIncludedValue, baseDir);
|
||||
const substituteResults = await substitute(rawIncludedValue, { dir });
|
||||
const includeValue = substituteResults.applied
|
||||
? substituteResults.value
|
||||
: rawIncludedValue;
|
||||
@@ -72,7 +77,7 @@ export function createIncludeTransform(
|
||||
switch (includeKey) {
|
||||
case '$file':
|
||||
try {
|
||||
const value = await readFile(resolvePath(baseDir, includeValue));
|
||||
const value = await readFile(resolvePath(dir, includeValue));
|
||||
return { applied: true, value: value.trimEnd() };
|
||||
} catch (error) {
|
||||
throw new Error(`failed to read file ${includeValue}, ${error}`);
|
||||
@@ -95,9 +100,9 @@ export function createIncludeTransform(
|
||||
);
|
||||
}
|
||||
|
||||
const path = resolvePath(baseDir, filePath);
|
||||
const path = resolvePath(dir, filePath);
|
||||
const content = await readFile(path);
|
||||
const newBaseDir = dirname(path);
|
||||
const newDir = dirname(path);
|
||||
|
||||
const parts = dataPath ? dataPath.split('.') : [];
|
||||
|
||||
@@ -121,10 +126,17 @@ export function createIncludeTransform(
|
||||
value = value[part];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const substituted = await substitute(value, { dir: newDir });
|
||||
if (substituted.applied) {
|
||||
value = substituted.value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
value,
|
||||
newBaseDir: newBaseDir !== baseDir ? newBaseDir : undefined,
|
||||
newDir: newDir !== dir ? newDir : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+3
-3
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { applyConfigTransforms } from './apply';
|
||||
export { createIncludeTransform } from './include';
|
||||
export { createSubstitutionTransform } from './substitution';
|
||||
export { createConfigTransformer } from './apply';
|
||||
export type { ConfigTransformer } from './apply';
|
||||
export type { ReadFileFunc, TransformContext, TransformFunc } from './types';
|
||||
+24
-16
@@ -29,52 +29,60 @@ const substituteTransform = createSubstitutionTransform(env);
|
||||
|
||||
describe('substituteTransform', () => {
|
||||
it('should not transform unknown values', async () => {
|
||||
await expect(substituteTransform(false, '/')).resolves.toEqual({
|
||||
await expect(substituteTransform(false, { dir: '/' })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(substituteTransform([1], '/')).resolves.toEqual({
|
||||
await expect(substituteTransform([1], { dir: '/' })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(substituteTransform(1, '/')).resolves.toEqual({
|
||||
await expect(substituteTransform(1, { dir: '/' })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(substituteTransform({ x: 'y' }, '/')).resolves.toEqual({
|
||||
await expect(
|
||||
substituteTransform({ x: 'y' }, { dir: '/' }),
|
||||
).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
await expect(substituteTransform(null, '/')).resolves.toEqual({
|
||||
await expect(substituteTransform(null, { dir: '/' })).resolves.toEqual({
|
||||
applied: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should substitute env var', async () => {
|
||||
await expect(substituteTransform('hello ${SECRET}', '/')).resolves.toEqual({
|
||||
await expect(
|
||||
substituteTransform('hello ${SECRET}', { dir: '/' }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: 'hello my-secret',
|
||||
});
|
||||
await expect(
|
||||
substituteTransform('${SECRET } $${} ${TOKEN }', '/'),
|
||||
substituteTransform('${SECRET } $${} ${TOKEN }', { dir: '/' }),
|
||||
).resolves.toEqual({ applied: true, value: 'my-secret ${} my-token' });
|
||||
await expect(substituteTransform('foo ${MISSING}', '/')).resolves.toEqual({
|
||||
applied: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(
|
||||
substituteTransform('empty substitute ${}', '/'),
|
||||
substituteTransform('foo ${MISSING}', { dir: '/' }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(
|
||||
substituteTransform('foo ${MISSING} ${SECRET}', '/'),
|
||||
substituteTransform('empty substitute ${}', { dir: '/' }),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(
|
||||
substituteTransform('foo ${MISSING} ${SECRET}', { dir: '/' }),
|
||||
).resolves.toEqual({ applied: true, value: undefined });
|
||||
await expect(
|
||||
substituteTransform('foo ${SECRET} ${SECRET}', '/'),
|
||||
substituteTransform('foo ${SECRET} ${SECRET}', { dir: '/' }),
|
||||
).resolves.toEqual({ applied: true, value: 'foo my-secret my-secret' });
|
||||
await expect(
|
||||
substituteTransform('foo ${SECRET} $$${ESCAPE_ME}', '/'),
|
||||
substituteTransform('foo ${SECRET} $$${ESCAPE_ME}', { dir: '/' }),
|
||||
).resolves.toEqual({ applied: true, value: 'foo my-secret $${ESCAPE_ME}' });
|
||||
await expect(
|
||||
substituteTransform('foo $${ESCAPE_ME} $$${ESCAPE_ME_TOO} $${}', '/'),
|
||||
substituteTransform('foo $${ESCAPE_ME} $$${ESCAPE_ME_TOO} $${}', {
|
||||
dir: '/',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
applied: true,
|
||||
value: 'foo ${ESCAPE_ME} $${ESCAPE_ME_TOO} ${}',
|
||||
+5
-2
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { TransformFunc, EnvFunc } from './types';
|
||||
import { TransformFunc } from './types';
|
||||
import { SubstitutionFunc } from '../types';
|
||||
|
||||
/**
|
||||
* A environment variable substitution transform that transforms e.g. 'token ${MY_TOKEN}'
|
||||
* to 'token abc' if MY_TOKEN is 'abc'. If any of the substituted variables are undefined,
|
||||
* the entire expression ends up undefined.
|
||||
*/
|
||||
export function createSubstitutionTransform(env: EnvFunc): TransformFunc {
|
||||
export function createSubstitutionTransform(
|
||||
env: SubstitutionFunc,
|
||||
): TransformFunc {
|
||||
return async (input: JsonValue) => {
|
||||
if (typeof input !== 'string') {
|
||||
return { applied: false };
|
||||
+6
-4
@@ -16,13 +16,15 @@
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export type EnvFunc = (name: string) => Promise<string | undefined>;
|
||||
|
||||
export type ReadFileFunc = (path: string) => Promise<string>;
|
||||
|
||||
export interface TransformContext {
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
export type TransformFunc = (
|
||||
value: JsonValue,
|
||||
baseDir: string,
|
||||
context: TransformContext,
|
||||
) => Promise<
|
||||
| {
|
||||
applied: false;
|
||||
@@ -30,6 +32,6 @@ export type TransformFunc = (
|
||||
| {
|
||||
applied: true;
|
||||
value: JsonValue | undefined;
|
||||
newBaseDir?: string | undefined;
|
||||
newDir?: string | undefined;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { AppConfig } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* The data returned by {@link ConfigSource.readConfigData}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ConfigSourceData extends AppConfig {
|
||||
/**
|
||||
* The file path that this configuration was loaded from, if it was loaded from a file.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link ConfigSource.readConfigData}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ReadConfigDataOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the iterator returned by {@link ConfigSource.readConfigData}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface AsyncConfigSourceIterator
|
||||
extends AsyncIterator<{ configs: ConfigSourceData[] }, void, void> {
|
||||
[Symbol.asyncIterator](): AsyncIterator<
|
||||
{ configs: ConfigSourceData[] },
|
||||
void,
|
||||
void
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of configuration data.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* It is recommended to implement the `readConfigData` method as an async generator.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* class MyConfigSource implements ConfigSource {
|
||||
* async *readConfigData() {
|
||||
* yield {
|
||||
* config: [{
|
||||
* context: 'example',
|
||||
* data: { backend: { baseUrl: 'http://localhost' } }
|
||||
* }]
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ConfigSource {
|
||||
readConfigData(options?: ReadConfigDataOptions): AsyncConfigSourceIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom function to be used for substitution withing configuration files.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Substitutions use the following syntax: `baseUrl: https://${HOSTNAME}`, where
|
||||
* `'HOSTNAME'` is the name of the variable to be substituted.
|
||||
*
|
||||
* The default substitution function will read the value of the environment.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SubstitutionFunc = (name: string) => Promise<string | undefined>;
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
export interface SimpleDeferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function simpleDefer<T>(): SimpleDeferred<T> {
|
||||
let resolve: (value: T) => void;
|
||||
const promise = new Promise<T>(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function waitOrAbort<T>(
|
||||
promise: PromiseLike<T>,
|
||||
signal?: AbortSignal | Array<AbortSignal | undefined>,
|
||||
): Promise<[ok: true, value: T] | [ok: false]> {
|
||||
const signals = [signal].flat().filter((x): x is AbortSignal => !!x);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signals.some(s => s.aborted)) {
|
||||
resolve([false]);
|
||||
}
|
||||
const onAbort = () => {
|
||||
resolve([false]);
|
||||
};
|
||||
promise.then(
|
||||
value => {
|
||||
resolve([true, value]);
|
||||
signals.forEach(s => s.removeEventListener('abort', onAbort));
|
||||
},
|
||||
error => {
|
||||
reject(error);
|
||||
signals.forEach(s => s.removeEventListener('abort', onAbort));
|
||||
},
|
||||
);
|
||||
signals.forEach(s => s.addEventListener('abort', onAbort));
|
||||
});
|
||||
}
|
||||
@@ -3923,6 +3923,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/config-loader@workspace:packages/config-loader"
|
||||
dependencies:
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/cli-common": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
@@ -3939,12 +3940,15 @@ __metadata:
|
||||
json-schema: ^0.4.0
|
||||
json-schema-merge-allof: ^0.8.1
|
||||
json-schema-traverse: ^1.0.0
|
||||
lodash: ^4.17.21
|
||||
minimist: ^1.2.5
|
||||
mock-fs: ^5.1.0
|
||||
msw: ^1.0.0
|
||||
node-fetch: ^2.6.7
|
||||
typescript-json-schema: ^0.55.0
|
||||
yaml: ^2.0.0
|
||||
yup: ^0.32.9
|
||||
zen-observable: ^0.10.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
||||
Reference in New Issue
Block a user