Merge pull request #3264 from backstage/rugvip/confs

config-loader: add configuration schema support
This commit is contained in:
Patrik Oldsberg
2020-11-18 19:18:25 +01:00
committed by GitHub
60 changed files with 2067 additions and 224 deletions
+148
View File
@@ -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;
}>;
};
}
+4 -2
View File
@@ -89,6 +89,8 @@
"supertest": "^4.0.2"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
-1
View File
@@ -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(
+5 -1
View File
@@ -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',
});
}
+43
View File
@@ -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."
}
}
}
}
}
}
}
}
+41 -5
View File
@@ -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);
};
+9
View File
@@ -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')
+10 -2
View File
@@ -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}`);
+5 -5
View File
@@ -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',
),
},
+3 -3
View File
@@ -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);
+8 -6
View File
@@ -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 = {
+31 -10
View File
@@ -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;
}
}
+10 -1
View File
@@ -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 =
+7
View File
@@ -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",
+2 -1
View File
@@ -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';
+1
View File
@@ -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' } },
});
});
});
-4
View File
@@ -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 = ({
-1
View File
@@ -28,7 +28,6 @@ export type SkipFunc = (path: string) => boolean;
*/
export type ReaderContext = {
env: { [name in string]?: string };
skip: SkipFunc;
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
+4 -55
View File
@@ -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([
{
+1 -22
View File
@@ -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),
}),
);
+65
View File
@@ -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;
}[];
};
}
+4 -2
View File
@@ -79,6 +79,8 @@
"@types/zen-observable": "^0.8.0"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
+5 -1
View File
@@ -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}`);
}