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
+1
View File
@@ -22,6 +22,7 @@
"dependencies": {
"@backstage/backend-common": "^0.2.0",
"@backstage/config-loader": "^0.2.0",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
+45 -26
View File
@@ -17,7 +17,7 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { injectEnvConfig } from './config';
import { injectConfig } from './config';
jest.mock('fs-extra');
@@ -29,12 +29,12 @@ const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
const MOCK_DIR = 'mock-dir';
const baseOptions = {
env: {},
appConfigs: [],
staticDir: MOCK_DIR,
logger: getVoidLogger(),
};
describe('injectEnvConfig', () => {
describe('injectConfig', () => {
beforeEach(() => {
fsMock.readdir.mockResolvedValue(['main.js']);
});
@@ -43,11 +43,23 @@ describe('injectEnvConfig', () => {
jest.resetAllMocks();
});
it('should not inject without config', async () => {
await injectEnvConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
it('should inject without config', async () => {
fsMock.readdir.mockResolvedValue(['main.js']);
readFileMock.mockImplementation(
async () => '"__APP_INJECTED_RUNTIME_CONFIG__"',
);
await injectConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(1);
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"[]"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual([]);
});
it('should find the correct file to inject', async () => {
@@ -64,7 +76,10 @@ describe('injectEnvConfig', () => {
return 'NO_PLACEHOLDER_HERE';
});
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
await injectConfig({
...baseOptions,
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
1,
@@ -80,14 +95,19 @@ describe('injectEnvConfig', () => {
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
'/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
x: 0,
});
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual([
{
data: {
x: 0,
},
context: 'test',
},
]);
});
it('should re-inject config', async () => {
@@ -96,41 +116,40 @@ describe('injectEnvConfig', () => {
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
);
await injectEnvConfig({
await injectConfig({
...baseOptions,
env: {
APP_CONFIG_x: '0',
},
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual([
{ data: { x: 0 }, context: 'test' },
]);
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
await injectEnvConfig({
await injectConfig({
...baseOptions,
env: {
APP_CONFIG_x: '1',
APP_CONFIG_y: '2',
},
appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }],
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":1,\\"y\\":2},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual([
{ data: { x: 1, y: 2 }, context: 'test' },
]);
});
});
+38 -15
View File
@@ -16,34 +16,27 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { readEnvConfig } from '@backstage/config-loader';
import { Logger } from 'winston';
import { AppConfig, Config, JsonObject } from '@backstage/config';
import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader';
type Options = {
// Environment to read config from
env: { [name: string]: string | undefined };
type InjectOptions = {
appConfigs: AppConfig[];
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: Logger;
};
/**
* Injects config from APP_CONFIG_ env vars, replacing existing
* injected config if it has already been injected.
* Injects configs into the app bundle, replacing any existing injected config.
*/
export async function injectEnvConfig(options: Options) {
const { env, staticDir, logger } = options;
const envConfig = readEnvConfig(env);
if (envConfig.length === 0) {
return;
}
export async function injectConfig(options: InjectOptions) {
const { staticDir, logger, appConfigs } = options;
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const [{ data }] = envConfig;
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
@@ -72,3 +65,33 @@ export async function injectEnvConfig(options: Options) {
}
logger.info('Env config not injected');
}
type ReadOptions = {
env: { [name: string]: string | undefined };
appDistDir: string;
config: Config;
};
/**
* Read config from environment and process the backend config using the
* schema that is embedded in the frontend build.
*/
export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const { env, appDistDir, config } = options;
const appConfigs = readEnvConfig(env);
const schemaPath = resolvePath(appDistDir, '.config-schema.json');
if (await fs.pathExists(schemaPath)) {
const serializedSchema = await fs.readJson(schemaPath);
const schema = await loadConfigSchema({ serialized: serializedSchema });
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visiblity: ['frontend'] },
);
appConfigs.push(...frontendConfigs);
}
return appConfigs;
}
@@ -16,13 +16,16 @@
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
jest.mock('../lib/config', () => ({
injectConfig: jest.fn(),
readConfigs: jest.fn(),
}));
global.__non_webpack_require__ = {
/* eslint-disable-next-line no-restricted-syntax */
@@ -35,6 +38,7 @@ describe('createRouter', () => {
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({}),
appPackageName: 'example-app',
});
app = express().use(router);
@@ -76,6 +80,7 @@ describe('createRouter with static fallback handler', () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({}),
appPackageName: 'example-app',
staticFallbackHandler,
});
+16 -9
View File
@@ -15,13 +15,15 @@
*/
import { resolve as resolvePath } from 'path';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { injectEnvConfig } from '../lib/config';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { injectConfig, readConfigs } from '../lib/config';
export interface RouterOptions {
config: Config;
logger: Logger;
appPackageName: string;
staticFallbackHandler?: express.Handler;
@@ -30,22 +32,27 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
options.logger.info(`Serving static app content from ${appDistDir}`);
const { config, logger, appPackageName, staticFallbackHandler } = options;
await injectEnvConfig({
const appDistDir = resolvePackagePath(appPackageName, 'dist');
logger.info(`Serving static app content from ${appDistDir}`);
const staticDir = resolvePath(appDistDir, 'static');
const appConfigs = await readConfigs({
config,
appDistDir,
env: process.env,
logger: options.logger,
staticDir: resolvePath(appDistDir, 'static'),
});
await injectConfig({ appConfigs, logger, staticDir });
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
if (options.staticFallbackHandler) {
staticRouter.use(options.staticFallbackHandler);
if (staticFallbackHandler) {
staticRouter.use(staticFallbackHandler);
}
staticRouter.use(notFoundHandler());
@@ -14,14 +14,16 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createServiceBuilder } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
config: Config;
logger: Logger;
}
@@ -32,6 +34,7 @@ export async function startStandaloneServer(
logger.debug('Starting application server...');
const router = await createRouter({
logger,
config: options.config,
appPackageName: 'example-app',
});
+52
View File
@@ -0,0 +1,52 @@
/*
* 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.
*/
interface Config {
costInsights: {
/**
* @visibility frontend
*/
engineerCost: number;
products: {
[kind: string]: {
/**
* @visibility frontend
*/
name: string;
/**
* @visibility frontend
*/
icon: 'compute' | 'data' | 'database' | 'storage' | 'search' | 'ml';
};
};
metrics?: {
[kind: string]: {
/**
* @visibility frontend
*/
name: string;
/**
* @visibility frontend
*/
default?: boolean;
};
};
};
}
+4 -2
View File
@@ -61,6 +61,8 @@
"msw": "^0.21.2"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
+17 -1
View File
@@ -52,5 +52,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/lighthouse",
"type": "object",
"properties": {
"lighthouse": {
"type": "object",
"properties": {
"baseUrl": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+17 -1
View File
@@ -52,5 +52,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/rollbar",
"type": "object",
"properties": {
"rollbar": {
"type": "object",
"properties": {
"organization": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+17 -1
View File
@@ -49,5 +49,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/sentry",
"type": "object",
"properties": {
"sentry": {
"type": "object",
"properties": {
"organization": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+23 -1
View File
@@ -53,5 +53,27 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/techdocs",
"type": "object",
"properties": {
"techdocs": {
"type": "object",
"properties": {
"requestUrl": {
"type": "string",
"visibility": "frontend"
},
"storageUrl": {
"type": "string"
}
},
"required": [
"requestUrl"
]
}
}
}
}
+20 -1
View File
@@ -45,5 +45,24 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/user-settings",
"type": "object",
"properties": {
"auth": {
"type": "object",
"properties": {
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"visibility": "frontend"
}
}
}
}
}
}
}