Merge pull request #3264 from backstage/rugvip/confs
config-loader: add configuration schema support
This commit is contained in:
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
app: {
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
};
|
||||
|
||||
backend: {
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
|
||||
/** Address that the backend should listen to. */
|
||||
listen:
|
||||
| string
|
||||
| {
|
||||
/** Address of the interface that the backend should bind to. */
|
||||
address?: string;
|
||||
/** Port that the backend should listen to. */
|
||||
port?: number;
|
||||
};
|
||||
|
||||
/** HTTPS configuration for the backend. If omitted the backend will serve HTTP */
|
||||
https?: {
|
||||
/** Certificate configuration or parameters for generating a self-signed certificate */
|
||||
certificate?:
|
||||
| {
|
||||
/** Algorithm to use to generate a self-signed certificate */
|
||||
algorithm: string;
|
||||
keySize?: number;
|
||||
days?: number;
|
||||
}
|
||||
| {
|
||||
/** PEM encoded certificate. Use $file to load in a file */
|
||||
cert: string;
|
||||
/**
|
||||
* PEM encoded certificate key. Use $file to load in a file.
|
||||
* @visibility secret
|
||||
*/
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Database connection configuration, select database type using the `client` field */
|
||||
database:
|
||||
| {
|
||||
client: 'sqlite3';
|
||||
connection: ':memory:' | string;
|
||||
}
|
||||
| {
|
||||
client: 'pg';
|
||||
/**
|
||||
* PostgreSQL connection string or knex configuration object.
|
||||
* @secret
|
||||
*/
|
||||
connection: string | object;
|
||||
};
|
||||
|
||||
cors?: {
|
||||
origin?: string | string[];
|
||||
methods?: string | string[];
|
||||
allowedHeaders?: string | string[];
|
||||
exposedHeaders?: string | string[];
|
||||
credentials?: boolean;
|
||||
maxAge?: number;
|
||||
preflightContinue?: boolean;
|
||||
optionsSuccessStatus?: number;
|
||||
};
|
||||
|
||||
/** */
|
||||
csp?: object;
|
||||
};
|
||||
|
||||
/** Configuration for integrations towards various external repository provider systems */
|
||||
integrations?: {
|
||||
/** Integration configuration for Azure */
|
||||
azure?: Array<{
|
||||
/** The hostname of the given Azure instance */
|
||||
host: string;
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
token?: string;
|
||||
}>;
|
||||
|
||||
/** Integration configuration for BitBucket */
|
||||
bitbucket?: Array<{
|
||||
/** The hostname of the given Bitbucket instance */
|
||||
host: string;
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
token?: string;
|
||||
/** The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 */
|
||||
apiBaseUrl?: string;
|
||||
/**
|
||||
* The username to use for authenticated requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* BitBucket app password used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
appPassword?: string;
|
||||
}>;
|
||||
|
||||
/** Integration configuration for GitHub */
|
||||
github?: Array<{
|
||||
/** The hostname of the given GitHub instance */
|
||||
host: string;
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
token?: string;
|
||||
/** The base url for the GitHub API, for example https://api.github.com */
|
||||
apiBaseUrl?: string;
|
||||
/** The base url for GitHub raw resources, for example https://raw.githubusercontent.com */
|
||||
rawBaseUrl?: string;
|
||||
}>;
|
||||
|
||||
/** Integration configuration for GitLab */
|
||||
gitlab?: Array<{
|
||||
/** The hostname of the given GitLab instance */
|
||||
host: string;
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
*/
|
||||
token?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -89,6 +89,8 @@
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export async function loadBackendConfig(options: Options): Promise<Config> {
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
|
||||
configRoot: paths.targetRoot,
|
||||
configPaths: configOpts.map(opt => resolvePath(opt)),
|
||||
shouldReadSecrets: true,
|
||||
});
|
||||
|
||||
options.logger.info(
|
||||
|
||||
@@ -17,9 +17,13 @@
|
||||
import { createRouter } from '@backstage/plugin-app-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({
|
||||
logger,
|
||||
config,
|
||||
appPackageName: 'example-app',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"jest": "^26.0.1",
|
||||
"jest-css-modules": "^2.1.0",
|
||||
"jest-esm-transformer": "^1.0.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"ora": "^4.0.3",
|
||||
"raw-loader": "^4.0.1",
|
||||
@@ -146,5 +147,47 @@
|
||||
"watch": "./src",
|
||||
"exec": "bin/backstage-cli",
|
||||
"ext": "ts"
|
||||
},
|
||||
"configSchema": {
|
||||
"$schema": "https://backstage.io/schema/config-v1",
|
||||
"title": "@backstage/cli",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"visibility": "frontend"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"visibility": "frontend"
|
||||
},
|
||||
"googleAnalyticsTrackingId": {
|
||||
"type": "string",
|
||||
"visibility": "frontend",
|
||||
"description": "Tracking ID for Google Analytics",
|
||||
"example": "UA-000000-0"
|
||||
},
|
||||
"listen": {
|
||||
"type": "object",
|
||||
"description": "Listening configuration for local development",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "number",
|
||||
"visibility": "frontend",
|
||||
"description": "The host that the frontend should be bound to. Only used for local development."
|
||||
},
|
||||
"post": {
|
||||
"type": "number",
|
||||
"visibility": "frontend",
|
||||
"description": "The port that the frontend should be bound to. Only used for local development."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,52 @@
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { AppConfig, ConfigReader } from '@backstage/config';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const { config } = await loadCliConfig(cmd.config, cmd.withSecrets ?? false);
|
||||
|
||||
const flatConfig = config.get();
|
||||
const { schema, appConfigs } = await loadCliConfig(cmd.config);
|
||||
const visibility = getVisiblityOption(cmd);
|
||||
const data = serializeConfigData(appConfigs, schema, visibility);
|
||||
|
||||
if (cmd.format === 'json') {
|
||||
process.stdout.write(`${JSON.stringify(flatConfig, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${stringifyYaml(flatConfig)}\n`);
|
||||
process.stdout.write(`${stringifyYaml(data)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
function getVisiblityOption(cmd: Command): ConfigVisibility {
|
||||
if (cmd.frontend && cmd.withSecrets) {
|
||||
throw new Error('Not allowed to combine frontend and secret config');
|
||||
}
|
||||
if (cmd.frontend) {
|
||||
return 'frontend';
|
||||
} else if (cmd.withSecrets) {
|
||||
return 'secret';
|
||||
}
|
||||
return 'backend';
|
||||
}
|
||||
|
||||
function serializeConfigData(
|
||||
appConfigs: AppConfig[],
|
||||
schema: ConfigSchema,
|
||||
visiblity: ConfigVisibility,
|
||||
) {
|
||||
if (visiblity === 'frontend') {
|
||||
const frontendConfigs = schema.process(appConfigs, {
|
||||
visiblity: ['frontend'],
|
||||
});
|
||||
return ConfigReader.fromConfigs(frontendConfigs).get();
|
||||
} else if (visiblity === 'secret') {
|
||||
return ConfigReader.fromConfigs(appConfigs).get();
|
||||
}
|
||||
|
||||
const sanitizedConfigs = schema.process(appConfigs, {
|
||||
valueTransform: (value, { visibility }) =>
|
||||
visibility === 'secret' ? '<secret>' : value,
|
||||
});
|
||||
|
||||
return ConfigReader.fromConfigs(sanitizedConfigs).get();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadCliConfig } from '../../lib/config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
await loadCliConfig(cmd.config);
|
||||
};
|
||||
@@ -136,6 +136,7 @@ export function registerCommands(program: CommanderStatic) {
|
||||
|
||||
program
|
||||
.command('config:print')
|
||||
.option('--frontend', 'Print only the frontend configuration')
|
||||
.option('--with-secrets', 'Include secrets in the printed configuration')
|
||||
.option(
|
||||
'--format <format>',
|
||||
@@ -145,6 +146,14 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.description('Print the app configuration for the current package')
|
||||
.action(lazy(() => import('./config/print').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('config:check')
|
||||
.option(...configOption)
|
||||
.description(
|
||||
'Validate that the given configuration loads and matches schema',
|
||||
)
|
||||
.action(lazy(() => import('./config/validate').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('prepack')
|
||||
.description('Prepares a package for packaging before publishing')
|
||||
|
||||
@@ -33,14 +33,14 @@ const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
|
||||
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
|
||||
|
||||
export async function buildBundle(options: BuildOptions) {
|
||||
const { statsJsonEnabled } = options;
|
||||
const { statsJsonEnabled, schema: configSchema } = options;
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const config = await createConfig(paths, {
|
||||
...options,
|
||||
checksEnabled: false,
|
||||
isDev: false,
|
||||
baseUrl: resolveBaseUrl(options.config),
|
||||
baseUrl: resolveBaseUrl(options.frontendConfig),
|
||||
});
|
||||
const compiler = webpack(config);
|
||||
|
||||
@@ -56,6 +56,14 @@ export async function buildBundle(options: BuildOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
if (configSchema) {
|
||||
await fs.writeJson(
|
||||
resolvePath(paths.targetDist, '.config-schema.json'),
|
||||
configSchema.serialize(),
|
||||
{ spaces: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
const { stats } = await build(compiler, isCi).catch(error => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
throw new Error(`Failed to compile.\n${error.message || error}`);
|
||||
|
||||
@@ -74,11 +74,11 @@ export async function createConfig(
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
): Promise<webpack.Configuration> {
|
||||
const { checksEnabled, isDev } = options;
|
||||
const { checksEnabled, isDev, frontendConfig } = options;
|
||||
|
||||
const { plugins, loaders } = transforms(options);
|
||||
|
||||
const baseUrl = options.config.getString('app.baseUrl');
|
||||
const baseUrl = frontendConfig.getString('app.baseUrl');
|
||||
const validBaseUrl = new URL(baseUrl);
|
||||
|
||||
if (checksEnabled) {
|
||||
@@ -99,7 +99,7 @@ export async function createConfig(
|
||||
|
||||
plugins.push(
|
||||
new webpack.EnvironmentPlugin({
|
||||
APP_CONFIG: options.appConfigs,
|
||||
APP_CONFIG: options.frontendAppConfigs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -109,9 +109,9 @@ export async function createConfig(
|
||||
templateParameters: {
|
||||
publicPath: validBaseUrl.pathname.replace(/\/$/, ''),
|
||||
app: {
|
||||
title: options.config.getString('app.title'),
|
||||
title: frontendConfig.getString('app.title'),
|
||||
baseUrl: validBaseUrl.href,
|
||||
googleAnalyticsTrackingId: options.config.getOptionalString(
|
||||
googleAnalyticsTrackingId: frontendConfig.getOptionalString(
|
||||
'app.googleAnalyticsTrackingId',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -23,12 +23,12 @@ import { ServeOptions } from './types';
|
||||
import { resolveBundlingPaths } from './paths';
|
||||
|
||||
export async function serveBundle(options: ServeOptions) {
|
||||
const url = resolveBaseUrl(options.config);
|
||||
const url = resolveBaseUrl(options.frontendConfig);
|
||||
|
||||
const host =
|
||||
options.config.getOptionalString('app.listen.host') || url.hostname;
|
||||
options.frontendConfig.getOptionalString('app.listen.host') || url.hostname;
|
||||
const port =
|
||||
options.config.getOptionalNumber('app.listen.port') ||
|
||||
options.frontendConfig.getOptionalNumber('app.listen.port') ||
|
||||
Number(url.port) ||
|
||||
(url.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
|
||||
@@ -17,27 +17,29 @@
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { BundlingPathsOptions } from './paths';
|
||||
import { ParallelOption } from '../parallel';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
|
||||
export type BundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
baseUrl: URL;
|
||||
parallel?: ParallelOption;
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type BuildOptions = BundlingPathsOptions & {
|
||||
statsJsonEnabled: boolean;
|
||||
parallel?: ParallelOption;
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
schema?: ConfigSchema;
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type BackendBundlingOptions = {
|
||||
|
||||
@@ -14,18 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { loadConfig, loadConfigSchema } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { paths } from './paths';
|
||||
|
||||
export async function loadCliConfig(
|
||||
configArgs: string[],
|
||||
shouldReadSecrets: boolean = false,
|
||||
) {
|
||||
export async function loadCliConfig(configArgs: string[]) {
|
||||
const configPaths = configArgs.map(arg => paths.resolveTarget(arg));
|
||||
|
||||
// Consider all packages in the monorepo when loading in config
|
||||
const LernaProject = require('@lerna/project');
|
||||
const project = new LernaProject(paths.targetDir);
|
||||
const packages = await project.getPackages();
|
||||
const localPackageNames = packages.map((p: any) => p.name);
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: localPackageNames,
|
||||
});
|
||||
|
||||
const appConfigs = await loadConfig({
|
||||
shouldReadSecrets,
|
||||
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production',
|
||||
configRoot: paths.targetRoot,
|
||||
configPaths,
|
||||
@@ -35,8 +40,24 @@ export async function loadCliConfig(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
return {
|
||||
appConfigs,
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
};
|
||||
try {
|
||||
const frontendAppConfigs = schema.process(appConfigs, {
|
||||
visiblity: ['frontend'],
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
return {
|
||||
schema,
|
||||
appConfigs,
|
||||
frontendConfig,
|
||||
frontendAppConfigs,
|
||||
};
|
||||
} catch (error) {
|
||||
const maybeSchemaError = error as Error & { messages?: string[] };
|
||||
if (maybeSchemaError.messages) {
|
||||
const messages = maybeSchemaError.messages.join('\n ');
|
||||
throw new Error(`Configuration does not match schema\n\n ${messages}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class PackageJsonHandler {
|
||||
await this.syncField('main:src');
|
||||
}
|
||||
await this.syncField('types');
|
||||
await this.syncField('files');
|
||||
await this.syncFiles();
|
||||
await this.syncScripts();
|
||||
await this.syncPublishConfig();
|
||||
await this.syncDependencies('dependencies');
|
||||
@@ -105,6 +105,15 @@ class PackageJsonHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles() {
|
||||
if (typeof this.targetPkg.configSchema === 'string') {
|
||||
const files = [...this.pkg.files, this.targetPkg.configSchema];
|
||||
await this.syncField('files', { files });
|
||||
} else {
|
||||
await this.syncField('files');
|
||||
}
|
||||
}
|
||||
|
||||
private async syncScripts() {
|
||||
const pkgScripts = this.pkg.scripts;
|
||||
const targetScripts = (this.targetPkg.scripts =
|
||||
|
||||
@@ -30,13 +30,20 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-common": "^0.1.1",
|
||||
"@backstage/config": "^0.1.1",
|
||||
"ajv": "^6.12.5",
|
||||
"fs-extra": "^9.0.0",
|
||||
"json-schema": "^0.2.5",
|
||||
"json-schema-merge-allof": "^0.7.0",
|
||||
"typescript-json-schema": "^0.43.0",
|
||||
"yaml": "^1.9.2",
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"@types/json-schema-merge-allof": "^0.6.0",
|
||||
"@types/mock-fs": "^4.10.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/yup": "^0.29.8",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { readEnvConfig } from './lib';
|
||||
export { readEnvConfig, loadConfigSchema } from './lib';
|
||||
export type { ConfigSchema, ConfigVisibility } from './lib';
|
||||
export { loadConfig } from './loader';
|
||||
export type { LoadConfigOptions } from './loader';
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
export { readConfigFile } from './reader';
|
||||
export { readEnvConfig } from './env';
|
||||
export { readSecret } from './secrets';
|
||||
export * from './schema';
|
||||
|
||||
@@ -28,7 +28,6 @@ function memoryFiles(files: { [path: string]: string }) {
|
||||
|
||||
const mockContext: ReaderContext = {
|
||||
env: {},
|
||||
skip: () => false,
|
||||
readFile: jest.fn(),
|
||||
readSecret: jest.fn(),
|
||||
};
|
||||
@@ -179,22 +178,4 @@ describe('readConfigFile', () => {
|
||||
|
||||
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
|
||||
});
|
||||
|
||||
it('should omit skipped values', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { title: skip, name: include }',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
...mockContext,
|
||||
readFile,
|
||||
skip: (path: string) => path === '.app.title',
|
||||
readSecret: jest.fn() as ReadSecretFunc,
|
||||
});
|
||||
|
||||
await expect(config).resolves.toEqual({
|
||||
context: 'app-config.yaml',
|
||||
data: { app: { name: 'include' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,10 +37,6 @@ export async function readConfigFile(
|
||||
obj: JsonValue,
|
||||
path: string,
|
||||
): Promise<JsonValue | undefined> {
|
||||
if (ctx.skip(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
} else if (obj === null) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import mockFs from 'mock-fs';
|
||||
import { collectConfigSchemas } from './collect';
|
||||
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
visibility: 'frontend',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('collectConfigSchemas', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should not find any schemas without packages', async () => {
|
||||
mockFs({
|
||||
'lerna.json': JSON.stringify({
|
||||
packages: ['packages/*'],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas([])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should find schema in a local package', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: mockSchema,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).resolves.toEqual([
|
||||
{
|
||||
path: 'node_modules/a/package.json',
|
||||
value: mockSchema,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find schema in transitive dependencies', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
dependencies: { b: '0.0.0', '@backstage/mock': '0.0.0' },
|
||||
}),
|
||||
},
|
||||
b: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'b',
|
||||
dependencies: {
|
||||
c1: '0.0.0',
|
||||
c2: '0.0.0',
|
||||
'@backstage/mock': '0.0.0',
|
||||
},
|
||||
configSchema: { ...mockSchema, title: 'b' },
|
||||
}),
|
||||
},
|
||||
c1: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'c1',
|
||||
dependencies: { d1: '0.0.0' },
|
||||
configSchema: { ...mockSchema, title: 'c1' },
|
||||
}),
|
||||
},
|
||||
c2: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'c2',
|
||||
dependencies: { d2: '0.0.0' },
|
||||
}),
|
||||
},
|
||||
d1: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'd1',
|
||||
dependencies: {},
|
||||
configSchema: { ...mockSchema, title: 'd1' },
|
||||
}),
|
||||
},
|
||||
d2: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'd2',
|
||||
dependencies: {},
|
||||
configSchema: { ...mockSchema, title: 'd2' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).resolves.toEqual([
|
||||
{
|
||||
path: 'node_modules/b/package.json',
|
||||
value: { ...mockSchema, title: 'b' },
|
||||
},
|
||||
{
|
||||
path: 'node_modules/c1/package.json',
|
||||
value: { ...mockSchema, title: 'c1' },
|
||||
},
|
||||
{
|
||||
path: 'node_modules/d1/package.json',
|
||||
value: { ...mockSchema, title: 'd1' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should schema of different types', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: { ...mockSchema, title: 'inline' },
|
||||
}),
|
||||
},
|
||||
b: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'b',
|
||||
configSchema: 'schema.json',
|
||||
}),
|
||||
'schema.json': JSON.stringify({ ...mockSchema, title: 'external' }),
|
||||
},
|
||||
c: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'c',
|
||||
configSchema: 'schema.d.ts',
|
||||
}),
|
||||
'schema.d.ts': `export interface Config {
|
||||
/** @visibility secret */
|
||||
tsKey: string
|
||||
}`,
|
||||
},
|
||||
},
|
||||
// TypeScript compilation needs to load some real files inside the typescript dir
|
||||
'../../node_modules/typescript': (mockFs as any).load(
|
||||
'../../node_modules/typescript',
|
||||
),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([
|
||||
{
|
||||
path: 'node_modules/a/package.json',
|
||||
value: { ...mockSchema, title: 'inline' },
|
||||
},
|
||||
{
|
||||
path: 'node_modules/b/schema.json',
|
||||
value: { ...mockSchema, title: 'external' },
|
||||
},
|
||||
{
|
||||
path: 'node_modules/c/schema.d.ts',
|
||||
value: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
tsKey: {
|
||||
type: 'string',
|
||||
visibility: 'secret',
|
||||
},
|
||||
},
|
||||
required: ['tsKey'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not allow unknown schema file types', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: 'schema.yaml',
|
||||
}),
|
||||
'schema.yaml': mockSchema,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).rejects.toThrow(
|
||||
'Config schema files must be .json or .d.ts, got schema.yaml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject typescript config declaration without a Config type', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: 'schema.d.ts',
|
||||
}),
|
||||
'schema.d.ts': `export interface NotConfig {}`,
|
||||
},
|
||||
},
|
||||
// TypeScript compilation needs to load some real files inside the typescript dir
|
||||
'../../node_modules/typescript': (mockFs as any).load(
|
||||
'../../node_modules/typescript',
|
||||
),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).rejects.toThrow(
|
||||
'Invalid schema in node_modules/a/schema.d.ts, missing Config export',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
resolve as resolvePath,
|
||||
relative as relativePath,
|
||||
dirname,
|
||||
sep,
|
||||
} from 'path';
|
||||
import { ConfigSchemaPackageEntry } from './types';
|
||||
import { getProgramFromFiles, generateSchema } from 'typescript-json-schema';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const req =
|
||||
typeof __non_webpack_require__ === 'undefined'
|
||||
? require
|
||||
: __non_webpack_require__;
|
||||
|
||||
/**
|
||||
* This collects all known config schemas across all dependencies of the app.
|
||||
*/
|
||||
export async function collectConfigSchemas(
|
||||
packageNames: string[],
|
||||
): Promise<ConfigSchemaPackageEntry[]> {
|
||||
const visitedPackages = new Set<string>();
|
||||
const schemas = Array<ConfigSchemaPackageEntry>();
|
||||
const tsSchemaPaths = Array<string>();
|
||||
const currentDir = await fs.realpath(process.cwd());
|
||||
|
||||
async function processItem({ name, parentPath }: Item) {
|
||||
// Ensures that we only process each package once. We don't bother with
|
||||
// loading in schemas from duplicates of different versions, as that's not
|
||||
// supported by Backstage right now anyway. We may want to change that in
|
||||
// the future though, if it for example becomes possible to load in two
|
||||
// different versions of e.g. @backstage/core at once.
|
||||
if (visitedPackages.has(name)) {
|
||||
return;
|
||||
}
|
||||
visitedPackages.add(name);
|
||||
|
||||
let pkgPath: string;
|
||||
try {
|
||||
pkgPath = req.resolve(
|
||||
`${name}/package.json`,
|
||||
parentPath && {
|
||||
paths: [parentPath],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// We can somewhat safely ignore packages that don't export package.json,
|
||||
// as they are likely not part of the Backstage ecosystem anyway.
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = await fs.readJson(pkgPath);
|
||||
const depNames = [
|
||||
...Object.keys(pkg.dependencies ?? {}),
|
||||
...Object.keys(pkg.peerDependencies ?? {}),
|
||||
];
|
||||
|
||||
// TODO(Rugvip): Trying this out to avoid having to traverse the full dependency graph,
|
||||
// since that's pretty slow. We probably need a better way to determine when
|
||||
// we've left the Backstage ecosystem, but this will do for now.
|
||||
const hasSchema = 'configSchema' in pkg;
|
||||
const hasBackstageDep = depNames.some(_ => _.startsWith('@backstage/'));
|
||||
if (!hasSchema && !hasBackstageDep) {
|
||||
return;
|
||||
}
|
||||
if (hasSchema) {
|
||||
if (typeof pkg.configSchema === 'string') {
|
||||
const isJson = pkg.configSchema.endsWith('.json');
|
||||
const isDts = pkg.configSchema.endsWith('.d.ts');
|
||||
if (!isJson && !isDts) {
|
||||
throw new Error(
|
||||
`Config schema files must be .json or .d.ts, got ${pkg.configSchema}`,
|
||||
);
|
||||
}
|
||||
if (isDts) {
|
||||
tsSchemaPaths.push(
|
||||
relativePath(
|
||||
currentDir,
|
||||
resolvePath(dirname(pkgPath), pkg.configSchema),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const path = resolvePath(dirname(pkgPath), pkg.configSchema);
|
||||
const value = await fs.readJson(path);
|
||||
schemas.push({
|
||||
value,
|
||||
path: relativePath(currentDir, path),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
schemas.push({
|
||||
value: pkg.configSchema,
|
||||
path: relativePath(currentDir, pkgPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
depNames.map(name => processItem({ name, parentPath: pkgPath })),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(packageNames.map(name => processItem({ name })));
|
||||
|
||||
const tsSchemas = compileTsSchemas(tsSchemaPaths);
|
||||
|
||||
return schemas.concat(tsSchemas);
|
||||
}
|
||||
|
||||
// This handles the support of TypeScript .d.ts config schema declarations.
|
||||
// We collect all typescript schema definition and compile them all in one go.
|
||||
// This is much faster than compiling them separately.
|
||||
function compileTsSchemas(paths: string[]) {
|
||||
if (paths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const program = getProgramFromFiles(paths, {
|
||||
incremental: false,
|
||||
isolatedModules: true,
|
||||
lib: ['ES5'], // Skipping most libs speeds processing up a lot, we just need the primitive types anyway
|
||||
noEmit: true,
|
||||
noResolve: true,
|
||||
skipLibCheck: true, // Skipping lib checks speeds things up
|
||||
skipDefaultLibCheck: true,
|
||||
strict: true,
|
||||
typeRoots: [], // Do not include any additional types
|
||||
types: [],
|
||||
});
|
||||
|
||||
const tsSchemas = paths.map(path => {
|
||||
let value;
|
||||
try {
|
||||
value = generateSchema(
|
||||
program,
|
||||
// All schemas should export a `Config` symbol
|
||||
'Config',
|
||||
// This enables usage of @visibility is doc comments
|
||||
{
|
||||
required: true,
|
||||
validationKeywords: ['visibility'],
|
||||
},
|
||||
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
|
||||
) as JsonObject | null;
|
||||
} catch (error) {
|
||||
if (error.message !== 'type Config not found') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Invalid schema in ${path}, missing Config export`);
|
||||
}
|
||||
return { path, value };
|
||||
});
|
||||
|
||||
return tsSchemas;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { compileConfigSchemas } from './compile';
|
||||
|
||||
describe('compileConfigSchemas', () => {
|
||||
it('should merge schemas', () => {
|
||||
const validate = compileConfigSchemas([
|
||||
{
|
||||
path: 'a',
|
||||
value: { type: 'object', properties: { a: { type: 'string' } } },
|
||||
},
|
||||
{
|
||||
path: 'b',
|
||||
value: { type: 'object', properties: { b: { type: 'number' } } },
|
||||
},
|
||||
]);
|
||||
expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({
|
||||
errors: ['Config should be string { type=string } at .a'],
|
||||
visibilityByPath: new Map(),
|
||||
});
|
||||
expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
|
||||
errors: ['Config should be number { type=number } at .b'],
|
||||
visibilityByPath: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should discover visibilities', () => {
|
||||
const validate = compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string', visibility: 'frontend' },
|
||||
b: { type: 'string', visibility: 'backend' },
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
visibility: 'secret',
|
||||
items: { type: 'string', visibility: 'frontend' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
b: { type: 'string', visibility: 'secret' },
|
||||
c: { type: 'string', visibility: 'backend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
visibility: 'secret',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validate([
|
||||
{ data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' },
|
||||
]),
|
||||
).toEqual({
|
||||
visibilityByPath: new Map(
|
||||
Object.entries({
|
||||
'.a': 'frontend',
|
||||
'.b': 'secret',
|
||||
'.d': 'secret',
|
||||
'.d.0': 'frontend',
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject visiblity conflicts', () => {
|
||||
expect(() =>
|
||||
compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string', visibility: 'frontend' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string', visibility: 'secret' } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow(
|
||||
"Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Ajv from 'ajv';
|
||||
import { JSONSchema7 as JSONSchema } from 'json-schema';
|
||||
import mergeAllOf, { Resolvers } from 'json-schema-merge-allof';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
ConfigSchemaPackageEntry,
|
||||
ValidationFunc,
|
||||
CONFIG_VISIBILITIES,
|
||||
ConfigVisibility,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* This takes a collection of Backstage configuration schemas from various
|
||||
* sources and compiles them down into a single schema validation function.
|
||||
*
|
||||
* It also handles the implementation of the custom "visibility" keyword used
|
||||
* to specify the scope of different config paths.
|
||||
*/
|
||||
export function compileConfigSchemas(
|
||||
schemas: ConfigSchemaPackageEntry[],
|
||||
): ValidationFunc {
|
||||
// The ajv instance below is stateful and doesn't really allow for additional
|
||||
// output during validation. We work around this by having this extra piece
|
||||
// of state that we reset before each validation.
|
||||
const visibilityByPath = new Map<string, ConfigVisibility>();
|
||||
|
||||
const ajv = new Ajv({
|
||||
allErrors: true,
|
||||
schemas: {
|
||||
'https://backstage.io/schema/config-v1': true,
|
||||
},
|
||||
}).addKeyword('visibility', {
|
||||
metaSchema: {
|
||||
type: 'string',
|
||||
enum: CONFIG_VISIBILITIES,
|
||||
},
|
||||
compile(visibility: ConfigVisibility) {
|
||||
return (_data, dataPath) => {
|
||||
if (!dataPath) {
|
||||
return false;
|
||||
}
|
||||
if (visibility && visibility !== 'backend') {
|
||||
const normalizedPath = dataPath.replace(
|
||||
/\['?(.*?)'?\]/g,
|
||||
(_, segment) => `.${segment}`,
|
||||
);
|
||||
visibilityByPath.set(normalizedPath, visibility);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const merged = mergeAllOf(
|
||||
{ allOf: schemas.map(_ => _.value) },
|
||||
{
|
||||
// JSONSchema is typically subtractive, as in it always reduces the set of allowed
|
||||
// inputs through constraints. This changes the object property merging to be additive
|
||||
// rather than subtractive.
|
||||
ignoreAdditionalProperties: true,
|
||||
resolvers: {
|
||||
// This ensures that the visibilities across different schemas are sound, and
|
||||
// selects the most specific visibility for each path.
|
||||
visibility(values: string[], path: string[]) {
|
||||
const hasFrontend = values.some(_ => _ === 'frontend');
|
||||
const hasSecret = values.some(_ => _ === 'secret');
|
||||
if (hasFrontend && hasSecret) {
|
||||
throw new Error(
|
||||
`Config schema visibility is both 'frontend' and 'secret' for ${path.join(
|
||||
'/',
|
||||
)}`,
|
||||
);
|
||||
} else if (hasFrontend) {
|
||||
return 'frontend';
|
||||
} else if (hasSecret) {
|
||||
return 'secret';
|
||||
}
|
||||
|
||||
return 'backend';
|
||||
},
|
||||
} as Partial<Resolvers<JSONSchema>>,
|
||||
},
|
||||
);
|
||||
|
||||
const validate = ajv.compile(merged);
|
||||
|
||||
return configs => {
|
||||
const config = ConfigReader.fromConfigs(configs).get();
|
||||
|
||||
visibilityByPath.clear();
|
||||
|
||||
const valid = validate(config);
|
||||
if (!valid) {
|
||||
const errors = validate.errors ?? [];
|
||||
return {
|
||||
errors: errors.map(({ dataPath, message, params }) => {
|
||||
const paramStr = Object.entries(params)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join(' ');
|
||||
return `Config ${message || ''} { ${paramStr} } at ${dataPath}`;
|
||||
}),
|
||||
visibilityByPath: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visibilityByPath: new Map(visibilityByPath),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { ConfigVisibility } from './types';
|
||||
import { filterByVisibility } from './filtering';
|
||||
|
||||
const data = {
|
||||
arr: ['f', 'b', 's'],
|
||||
objArr: [
|
||||
{ f: 1, b: 2, s: 3 },
|
||||
{ f: 4, b: 5, s: 6 },
|
||||
],
|
||||
obj: {
|
||||
f: 'a',
|
||||
b: {
|
||||
s: true,
|
||||
},
|
||||
},
|
||||
arrF: [{ never: 'here' }],
|
||||
arrB: [{ never: 'here' }],
|
||||
arrS: [{ never: 'here' }],
|
||||
objF: { never: 'here' },
|
||||
objB: { never: 'here' },
|
||||
objS: { never: 'here' },
|
||||
};
|
||||
|
||||
const visiblity = new Map<string, ConfigVisibility>(
|
||||
Object.entries({
|
||||
'.arr.0': 'frontend',
|
||||
'.arr.1': 'backend',
|
||||
'.arr.2': 'secret',
|
||||
'.obj.f': 'frontend',
|
||||
'.obj.b': 'backend',
|
||||
'.obj.b.s': 'secret',
|
||||
'.objArr.0.f': 'frontend',
|
||||
'.objArr.0.b': 'backend',
|
||||
'.objArr.0.s': 'secret',
|
||||
'.objArr.1.f': 'frontend',
|
||||
'.objArr.1.b': 'backend',
|
||||
'.objArr.1.s': 'secret',
|
||||
'.arrF': 'frontend',
|
||||
'.arrB': 'backend',
|
||||
'.arrS': 'secret',
|
||||
'.objF': 'frontend',
|
||||
'.objB': 'backend',
|
||||
'.objS': 'secret',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('filterByVisibility', () => {
|
||||
test.each<[ConfigVisibility[], JsonObject]>([
|
||||
[[], {}],
|
||||
[
|
||||
['frontend'],
|
||||
{
|
||||
arr: ['f'],
|
||||
objArr: [{ f: 1 }, { f: 4 }],
|
||||
obj: { f: 'a' },
|
||||
arrF: [],
|
||||
objF: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
['backend'],
|
||||
{
|
||||
arr: ['b'],
|
||||
objArr: [{ b: 2 }, { b: 5 }],
|
||||
obj: { b: {} },
|
||||
arrF: [{ never: 'here' }],
|
||||
arrB: [{ never: 'here' }],
|
||||
arrS: [{ never: 'here' }],
|
||||
objF: { never: 'here' },
|
||||
objB: { never: 'here' },
|
||||
objS: { never: 'here' },
|
||||
},
|
||||
],
|
||||
[
|
||||
['secret'],
|
||||
{
|
||||
arr: ['s'],
|
||||
objArr: [{ s: 3 }, { s: 6 }],
|
||||
obj: { b: { s: true } },
|
||||
arrS: [],
|
||||
objS: {},
|
||||
},
|
||||
],
|
||||
[['frontend', 'backend', 'secret'], data],
|
||||
])('should filter correctly with %p', (filter, expected) => {
|
||||
expect(filterByVisibility(data, filter, visiblity)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import {
|
||||
ConfigVisibility,
|
||||
DEFAULT_CONFIG_VISIBILITY,
|
||||
TransformFunc,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* This filters data by visibility by discovering the visibility of each
|
||||
* value, and then only keeping the ones that are specified in `includeVisibilities`.
|
||||
*/
|
||||
export function filterByVisibility(
|
||||
data: JsonObject,
|
||||
includeVisibilities: ConfigVisibility[],
|
||||
visibilityByPath: Map<string, ConfigVisibility>,
|
||||
transformFunc?: TransformFunc<number | string | boolean>,
|
||||
): JsonObject {
|
||||
function transform(jsonVal: JsonValue, path: string): JsonValue | undefined {
|
||||
const visibility = visibilityByPath.get(path) ?? DEFAULT_CONFIG_VISIBILITY;
|
||||
const isVisible = includeVisibilities.includes(visibility);
|
||||
|
||||
if (typeof jsonVal !== 'object') {
|
||||
if (isVisible) {
|
||||
if (transformFunc) {
|
||||
return transformFunc(jsonVal, { visibility });
|
||||
}
|
||||
return jsonVal;
|
||||
}
|
||||
return undefined;
|
||||
} else if (jsonVal === null) {
|
||||
return undefined;
|
||||
} else if (Array.isArray(jsonVal)) {
|
||||
const arr = new Array<JsonValue>();
|
||||
|
||||
for (const [index, value] of jsonVal.entries()) {
|
||||
const out = transform(value, `${path}.${index}`);
|
||||
if (out !== undefined) {
|
||||
arr.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.length > 0 || isVisible) {
|
||||
return arr;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const outObj: JsonObject = {};
|
||||
let hasOutput = false;
|
||||
|
||||
for (const [key, value] of Object.entries(jsonVal)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const out = transform(value, `${path}.${key}`);
|
||||
if (out !== undefined) {
|
||||
outObj[key] = out;
|
||||
hasOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutput || isVisible) {
|
||||
return outObj;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (transform(data, '') as JsonObject) ?? {};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { loadConfigSchema } from './load';
|
||||
export type { ConfigSchema, ConfigVisibility } from './types';
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import mockFs from 'mock-fs';
|
||||
import { loadConfigSchema } from './load';
|
||||
|
||||
describe('loadConfigSchema', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should load schema from packages or data', async () => {
|
||||
mockFs({
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key1: { type: 'string', visibility: 'frontend' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
b: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'b',
|
||||
configSchema: 'schema.json',
|
||||
}),
|
||||
'schema.json': JSON.stringify({
|
||||
name: 'a',
|
||||
configSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key2: { type: 'number' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: ['a'],
|
||||
});
|
||||
|
||||
const configs = [{ data: { key1: 'a', key2: 2 }, context: 'test' }];
|
||||
|
||||
expect(schema.process(configs)).toEqual(configs);
|
||||
expect(schema.process(configs, { visiblity: ['frontend'] })).toEqual([
|
||||
{ data: { key1: 'a' }, context: 'test' },
|
||||
]);
|
||||
expect(
|
||||
schema.process(configs, {
|
||||
visiblity: ['frontend'],
|
||||
valueTransform: () => 'X',
|
||||
}),
|
||||
).toEqual([{ data: { key1: 'X' }, context: 'test' }]);
|
||||
expect(
|
||||
schema.process(configs, {
|
||||
valueTransform: () => 'X',
|
||||
}),
|
||||
).toEqual([{ data: { key1: 'X', key2: 'X' }, context: 'test' }]);
|
||||
|
||||
const serialized = schema.serialize();
|
||||
|
||||
const schema2 = await loadConfigSchema({ serialized });
|
||||
expect(schema2.process(configs, { visiblity: ['frontend'] })).toEqual([
|
||||
{ data: { key1: 'a' }, context: 'test' },
|
||||
]);
|
||||
expect(() =>
|
||||
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
|
||||
).toThrow(
|
||||
'Config validation failed, Config should be string { type=string } at .key1',
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfigSchema({
|
||||
serialized: { ...serialized, backstageConfigSchemaVersion: 2 },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Serialized configuration schema is invalid or has an invalid version number',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { compileConfigSchemas } from './compile';
|
||||
import { collectConfigSchemas } from './collect';
|
||||
import { filterByVisibility } from './filtering';
|
||||
import {
|
||||
ConfigSchema,
|
||||
ConfigSchemaPackageEntry,
|
||||
CONFIG_VISIBILITIES,
|
||||
} from './types';
|
||||
|
||||
type Options =
|
||||
| {
|
||||
dependencies: string[];
|
||||
}
|
||||
| {
|
||||
serialized: JsonObject;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads config schema for a Backstage instance.
|
||||
*/
|
||||
export async function loadConfigSchema(
|
||||
options: Options,
|
||||
): Promise<ConfigSchema> {
|
||||
let schemas: ConfigSchemaPackageEntry[];
|
||||
|
||||
if ('dependencies' in options) {
|
||||
schemas = await collectConfigSchemas(options.dependencies);
|
||||
} else {
|
||||
const { serialized } = options;
|
||||
if (serialized?.backstageConfigSchemaVersion !== 1) {
|
||||
throw new Error(
|
||||
'Serialized configuration schema is invalid or has an invalid version number',
|
||||
);
|
||||
}
|
||||
schemas = serialized.schemas as ConfigSchemaPackageEntry[];
|
||||
}
|
||||
|
||||
const validate = compileConfigSchemas(schemas);
|
||||
|
||||
return {
|
||||
process(
|
||||
configs: AppConfig[],
|
||||
{ visiblity, valueTransform } = {},
|
||||
): AppConfig[] {
|
||||
const result = validate(configs);
|
||||
if (result.errors) {
|
||||
const error = new Error(
|
||||
`Config validation failed, ${result.errors.join('; ')}`,
|
||||
);
|
||||
(error as any).messages = result.errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let processedConfigs = configs;
|
||||
|
||||
if (visiblity) {
|
||||
processedConfigs = processedConfigs.map(({ data, context }) => ({
|
||||
context,
|
||||
data: filterByVisibility(
|
||||
data,
|
||||
visiblity,
|
||||
result.visibilityByPath,
|
||||
valueTransform,
|
||||
),
|
||||
}));
|
||||
} else if (valueTransform) {
|
||||
processedConfigs = processedConfigs.map(({ data, context }) => ({
|
||||
context,
|
||||
data: filterByVisibility(
|
||||
data,
|
||||
Array.from(CONFIG_VISIBILITIES),
|
||||
result.visibilityByPath,
|
||||
valueTransform,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return processedConfigs;
|
||||
},
|
||||
serialize(): JsonObject {
|
||||
return {
|
||||
schemas,
|
||||
backstageConfigSchemaVersion: 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* An sub-set of configuration schema.
|
||||
*/
|
||||
export type ConfigSchemaPackageEntry = {
|
||||
/**
|
||||
* The configuration schema itself.
|
||||
*/
|
||||
value: JsonObject;
|
||||
/**
|
||||
* The relative path that the configuration schema was discovered at.
|
||||
*/
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of all possible configuration value visibilities.
|
||||
*/
|
||||
export const CONFIG_VISIBILITIES = ['frontend', 'backend', 'secret'] as const;
|
||||
|
||||
/**
|
||||
* A type representing the possible configuration value visibilities
|
||||
*/
|
||||
export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number];
|
||||
|
||||
/**
|
||||
* The default configuration visibility if no other values is given.
|
||||
*/
|
||||
export const DEFAULT_CONFIG_VISIBILITY: ConfigVisibility = 'backend';
|
||||
|
||||
/**
|
||||
* An explanation of a configuration validation error.
|
||||
*/
|
||||
type ValidationError = string;
|
||||
|
||||
/**
|
||||
* The result of validating configuration data using a schema.
|
||||
*/
|
||||
type ValidationResult = {
|
||||
/**
|
||||
* Errors that where emitted during validation, if any.
|
||||
*/
|
||||
errors?: ValidationError[];
|
||||
/**
|
||||
* The configuration visibilities that were discovered during validation.
|
||||
*
|
||||
* The path in the key uses the form `/<key>/<sub-key>/<array-index>/<leaf-key>`
|
||||
*/
|
||||
visibilityByPath: Map<string, ConfigVisibility>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function used validate configuration data.
|
||||
*/
|
||||
export type ValidationFunc = (configs: AppConfig[]) => ValidationResult;
|
||||
|
||||
/**
|
||||
* A function used to transform primitive configuration values.
|
||||
*/
|
||||
export type TransformFunc<T extends number | string | boolean> = (
|
||||
value: T,
|
||||
context: { visibility: ConfigVisibility },
|
||||
) => T | undefined;
|
||||
|
||||
/**
|
||||
* Options used to process configuration data with a schema.
|
||||
*/
|
||||
type ConfigProcessingOptions = {
|
||||
/**
|
||||
* The visibilities that should be included in the output data.
|
||||
* If omitted, the data will not be filtered by visibility.
|
||||
*/
|
||||
visiblity?: ConfigVisibility[];
|
||||
|
||||
/**
|
||||
* A transform function that can be used to transform primitive configuration values
|
||||
* during validation. The value returned from the transform function will be used
|
||||
* instead of the original value. If the transform returns `undefined`, the value
|
||||
* will be omitted.
|
||||
*/
|
||||
valueTransform?: TransformFunc<any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A loaded configuration schema that is ready to process configuration data.
|
||||
*/
|
||||
export type ConfigSchema = {
|
||||
process(
|
||||
appConfigs: AppConfig[],
|
||||
options?: ConfigProcessingOptions,
|
||||
): AppConfig[];
|
||||
|
||||
serialize(): JsonObject;
|
||||
};
|
||||
@@ -21,7 +21,6 @@ const ctx: ReaderContext = {
|
||||
env: {
|
||||
SECRET: 'my-secret',
|
||||
},
|
||||
skip: () => false,
|
||||
readSecret: jest.fn(),
|
||||
async readFile(path) {
|
||||
const content = ({
|
||||
|
||||
@@ -28,7 +28,6 @@ export type SkipFunc = (path: string) => boolean;
|
||||
*/
|
||||
export type ReaderContext = {
|
||||
env: { [name in string]?: string };
|
||||
skip: SkipFunc;
|
||||
readFile: ReadFileFunc;
|
||||
readSecret: ReadSecretFunc;
|
||||
};
|
||||
|
||||
@@ -44,47 +44,6 @@ describe('loadConfig', () => {
|
||||
configRoot: '/root',
|
||||
configPaths: [],
|
||||
env: 'production',
|
||||
shouldReadSecrets: false,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
context: 'app-config.yaml',
|
||||
data: {
|
||||
app: {
|
||||
title: 'Example App',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads config without secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
configRoot: '/root',
|
||||
configPaths: ['/root/app-config.yaml'],
|
||||
env: 'production',
|
||||
shouldReadSecrets: false,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
context: 'app-config.yaml',
|
||||
data: {
|
||||
app: {
|
||||
title: 'Example App',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads config with secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
configRoot: '/root',
|
||||
configPaths: ['/root/app-config.yaml'],
|
||||
env: 'production',
|
||||
shouldReadSecrets: true,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
@@ -99,16 +58,12 @@ describe('loadConfig', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads development config without secrets', async () => {
|
||||
it('loads config with secrets', async () => {
|
||||
await expect(
|
||||
loadConfig({
|
||||
configRoot: '/root',
|
||||
configPaths: [
|
||||
'/root/app-config.yaml',
|
||||
'/root/app-config.development.yaml',
|
||||
],
|
||||
env: 'development',
|
||||
shouldReadSecrets: false,
|
||||
configPaths: ['/root/app-config.yaml'],
|
||||
env: 'production',
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
@@ -116,15 +71,10 @@ describe('loadConfig', () => {
|
||||
data: {
|
||||
app: {
|
||||
title: 'Example App',
|
||||
sessionKey: 'abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'app-config.development.yaml',
|
||||
data: {
|
||||
app: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -137,7 +87,6 @@ describe('loadConfig', () => {
|
||||
'/root/app-config.development.yaml',
|
||||
],
|
||||
env: 'development',
|
||||
shouldReadSecrets: true,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
|
||||
@@ -28,18 +28,13 @@ export type LoadConfigOptions = {
|
||||
|
||||
// TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes.
|
||||
env: string;
|
||||
|
||||
// Whether to read secrets or omit them, defaults to false.
|
||||
shouldReadSecrets?: boolean;
|
||||
};
|
||||
|
||||
class Context {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
secretPaths: Set<string>;
|
||||
env: { [name in string]?: string };
|
||||
rootPath: string;
|
||||
shouldReadSecrets: boolean;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -47,26 +42,14 @@ class Context {
|
||||
return this.options.env;
|
||||
}
|
||||
|
||||
skip(path: string): boolean {
|
||||
if (this.options.shouldReadSecrets) {
|
||||
return false;
|
||||
}
|
||||
return this.options.secretPaths.has(path);
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
|
||||
}
|
||||
|
||||
async readSecret(
|
||||
path: string,
|
||||
_path: string,
|
||||
desc: JsonObject,
|
||||
): Promise<string | undefined> {
|
||||
this.options.secretPaths.add(path);
|
||||
if (!this.options.shouldReadSecrets) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return readSecret(desc, this);
|
||||
}
|
||||
}
|
||||
@@ -100,8 +83,6 @@ export async function loadConfig(
|
||||
}
|
||||
|
||||
try {
|
||||
const secretPaths = new Set<string>();
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
if (!isAbsolute(configPath)) {
|
||||
throw new Error(`Config load path is not absolute: '${configPath}'`);
|
||||
@@ -109,10 +90,8 @@ export async function loadConfig(
|
||||
const config = await readConfigFile(
|
||||
configPath,
|
||||
new Context({
|
||||
secretPaths,
|
||||
env: process.env,
|
||||
rootPath: dirname(configPath),
|
||||
shouldReadSecrets: Boolean(options.shouldReadSecrets),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Generic frontend configuration.
|
||||
*/
|
||||
app: {
|
||||
/**
|
||||
* The public absolute root URL that the frontend.
|
||||
* @visibility frontend
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The title of the app.
|
||||
* @visibility frontend
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic backend configuration.
|
||||
*/
|
||||
backend: {
|
||||
/**
|
||||
* The public absolute root URL that the backend is reachable at.
|
||||
* @visibility frontend
|
||||
*/
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration that provides information about the organization that the app is for.
|
||||
*/
|
||||
organization?: {
|
||||
/**
|
||||
* The name of the organization that the app belongs to.
|
||||
* @visibility frontend
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
homepage?: {
|
||||
clocks?: {
|
||||
/** @visibility frontend */
|
||||
label: string;
|
||||
/** @visibility frontend */
|
||||
timezone: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
@@ -79,6 +79,8 @@
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,11 @@ export const defaultConfigLoader: AppConfigLoader = async (
|
||||
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
|
||||
try {
|
||||
const data = JSON.parse(runtimeConfigJson) as JsonObject;
|
||||
configs.push({ data, context: 'env' });
|
||||
if (Array.isArray(data)) {
|
||||
configs.push(...data);
|
||||
} else {
|
||||
configs.push({ data, context: 'env' });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load runtime configuration, ${error}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user