From d7adbbf455a7395ef37098d3c2fbef090905eeed Mon Sep 17 00:00:00 2001 From: David Festal Date: Wed, 29 Nov 2023 16:37:36 +0100 Subject: [PATCH 1/8] Support external config schemas in the backend. Signed-off-by: David Festal --- .changeset/swift-pumpkins-shake.md | 12 ++ packages/backend-app-api/api-report.md | 2 + packages/backend-app-api/src/config/config.ts | 3 + .../rootLoggerServiceFactory.test.ts | 134 ++++++++++++++++++ .../rootLogger/rootLoggerServiceFactory.ts | 11 +- packages/backend-common/api-report.md | 2 + packages/backend-common/src/config.test.ts | 87 ++++++++++++ packages/backend-common/src/config.ts | 7 +- .../api-report.md | 11 ++ .../package.json | 3 +- .../src/scanner/index.ts | 2 + .../src/scanner/plugin-scanner.ts | 77 +++++++++- .../src/scanner/schemas.ts | 75 ++++++++++ .../backend-plugin-api/api-report-alpha.md | 15 ++ packages/backend-plugin-api/package.json | 1 + packages/backend-plugin-api/src/alpha.ts | 32 +++++ packages/config-loader/api-report.md | 7 + packages/config-loader/src/index.ts | 1 + packages/config-loader/src/schema/index.ts | 1 + .../config-loader/src/schema/load.test.ts | 54 +++++++ packages/config-loader/src/schema/load.ts | 2 + packages/config-loader/src/schema/types.ts | 4 +- plugins/app-backend/api-report.md | 2 + plugins/app-backend/src/lib/config.ts | 7 +- plugins/app-backend/src/service/appPlugin.ts | 7 +- plugins/app-backend/src/service/router.ts | 13 ++ yarn.lock | 2 + 27 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 .changeset/swift-pumpkins-shake.md create mode 100644 packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts create mode 100644 packages/backend-common/src/config.test.ts create mode 100644 packages/backend-dynamic-feature-service/src/scanner/schemas.ts diff --git a/.changeset/swift-pumpkins-shake.md b/.changeset/swift-pumpkins-shake.md new file mode 100644 index 0000000000..c9441a3690 --- /dev/null +++ b/.changeset/swift-pumpkins-shake.md @@ -0,0 +1,12 @@ +--- +'@backstage/backend-app-api': minor +'@backstage/backend-common': minor +'@backstage/config-loader': minor +'@backstage/plugin-app-backend': minor +'@backstage/backend-dynamic-feature-service': minor +--- + +Allow referencing additional configuration schemas at runtime (application start), and have them taken in account where schemas are used: + +- the backed-app plugin, in order to prepare the frontend configuration +- the backend application loggers, in order to hide secret configuration values (defined in the schemas). diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index e478a91bb7..1143beec15 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -9,6 +9,7 @@ import type { AppConfig } from '@backstage/config'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CacheClient } from '@backstage/backend-common'; import { Config } from '@backstage/config'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import { CorsOptions } from 'cors'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { ErrorRequestHandler } from 'express'; @@ -64,6 +65,7 @@ export const cacheServiceFactory: () => ServiceFactory; export function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; + additionalSchemas?: ConfigSchemaPackageEntry[]; }): Promise<(config: Config) => Iterable>; // @public diff --git a/packages/backend-app-api/src/config/config.ts b/packages/backend-app-api/src/config/config.ts index 8ecc015b66..d4eb5fccb9 100644 --- a/packages/backend-app-api/src/config/config.ts +++ b/packages/backend-app-api/src/config/config.ts @@ -23,6 +23,7 @@ import { loadConfig, ConfigTarget, LoadConfigOptionsRemote, + ConfigSchemaPackageEntry, } from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import type { Config, AppConfig } from '@backstage/config'; @@ -34,11 +35,13 @@ import { isValidUrl } from '../lib/urls'; export async function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; + additionalSchemas?: ConfigSchemaPackageEntry[]; }): Promise<(config: Config) => Iterable> { const { logger, dir = process.cwd() } = options; const { packages } = await getPackages(dir); const schema = await loadConfigSchema({ dependencies: packages.map(p => p.packageJson.name), + additionalSchemas: options.additionalSchemas, }); return (config: Config) => { diff --git a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts new file mode 100644 index 0000000000..ac7b9e8cf2 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSpecializedBackend } from '../../../wiring'; +import { rootLoggerServiceFactory } from './rootLoggerServiceFactory'; +import { + coreServices, + createBackendPlugin, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { + ConfigSchemaPackageEntry, + ConfigSources, + StaticConfigSource, +} from '@backstage/config-loader'; +import { transports } from 'winston'; +import { rootLifecycleServiceFactory } from '../rootLifecycle'; +import { lifecycleServiceFactory } from '../lifecycle'; +import { loggerServiceFactory } from '../logger'; + +describe('rootLogger', () => { + describe('rootLoggerServiceFactory', () => { + afterEach(() => { + jest.resetModules(); + }); + + it('also hide secret config values coming from additional schemas', async () => { + const additionalConfigs = { + context: 'test', + data: { + secretValue: 'shouldBeHidden', + }, + }; + + const additionalSchemas = [ + { + path: 'test', + value: { + type: 'object', + properties: { + secretValue: { + type: 'string', + visibility: 'secret', + }, + }, + }, + }, + ]; + + const logs: string[] = []; + jest + .spyOn(transports.Console.prototype, 'log') + .mockImplementation((s: any, next: any) => { + logs.push(s.message); + if (next) { + next(); + } + return undefined; + }); + + const backend = createSpecializedBackend({ + defaultServiceFactories: [ + lifecycleServiceFactory(), + loggerServiceFactory(), + rootLifecycleServiceFactory(), + rootLoggerServiceFactory(), + createServiceFactory({ + service: coreServices.rootConfig, + deps: {}, + async factory() { + return await ConfigSources.toConfig( + ConfigSources.merge([ + ConfigSources.default({}), + StaticConfigSource.create(additionalConfigs), + ]), + ); + }, + }), + createServiceFactory({ + service: schemaDiscoveryServiceRef, + deps: { + config: coreServices.rootConfig, + }, + factory: async () => ({ + getAdditionalSchemas: async (): Promise<{ + schemas: Array; + }> => ({ + schemas: additionalSchemas, + }), + }), + }), + ], + }); + backend.add( + createBackendPlugin({ + pluginId: 'test', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + lifecycle: coreServices.lifecycle, + }, + async init({ logger }) { + logger.info('test'); + logger.info('shouldBeHidden'); + }, + }); + }, + }), + ); + await backend.start(); + + expect(logs[0]).toMatch( + /Found \d+ new secrets in config that will be redacted/, + ); + expect(logs[1]).toEqual('test'); + expect(logs[2]).toEqual('[REDACTED]'); + }, 30000); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts index bd45df383f..becf618f4f 100644 --- a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts @@ -18,6 +18,7 @@ import { createServiceFactory, coreServices, } from '@backstage/backend-plugin-api'; +import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { WinstonLogger } from '../../../logging'; import { transports, format } from 'winston'; import { createConfigSecretEnumerator } from '../../../config'; @@ -27,8 +28,9 @@ export const rootLoggerServiceFactory = createServiceFactory({ service: coreServices.rootLogger, deps: { config: coreServices.rootConfig, + schemaDiscovery: schemaDiscoveryServiceRef, }, - async factory({ config }) { + async factory({ config, schemaDiscovery }) { const logger = WinstonLogger.create({ meta: { service: 'backstage', @@ -41,7 +43,12 @@ export const rootLoggerServiceFactory = createServiceFactory({ transports: [new transports.Console()], }); - const secretEnumerator = await createConfigSecretEnumerator({ logger }); + const secretEnumerator = await createConfigSecretEnumerator({ + logger, + additionalSchemas: ( + await schemaDiscovery?.getAdditionalSchemas() + )?.schemas, + }); logger.addRedactions(secretEnumerator(config)); config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e4a1ecfdf1..7a3fc72a5b 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -20,6 +20,7 @@ import { CacheService as CacheClient } from '@backstage/backend-plugin-api'; import { CacheServiceOptions as CacheClientOptions } from '@backstage/backend-plugin-api'; import { CacheServiceSetOptions as CacheClientSetOptions } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import cors from 'cors'; import Docker from 'dockerode'; import { ErrorRequestHandler } from 'express'; @@ -560,6 +561,7 @@ export function loadBackendConfig(options: { logger: LoggerService; remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; + additionalSchemas?: ConfigSchemaPackageEntry[]; argv: string[]; watch?: boolean; }): Promise; diff --git a/packages/backend-common/src/config.test.ts b/packages/backend-common/src/config.test.ts new file mode 100644 index 0000000000..931c0b5c84 --- /dev/null +++ b/packages/backend-common/src/config.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { transports } from 'winston'; +import { loadBackendConfig } from './config'; +import { getRootLogger } from './logging'; + +describe('config', () => { + describe('loadBackendConfig', () => { + const env = process.env; + afterEach(() => { + jest.resetModules(); + process.env = env; + }); + + it('also hide secret config values coming from additional schemas', async () => { + const additionalConfigs = [ + { + context: 'test', + data: { + secretValue: 'shouldBeHidden', + }, + }, + ]; + + const additionalSchemas = [ + { + path: 'test', + value: { + type: 'object', + properties: { + secretValue: { + type: 'string', + visibility: 'secret', + }, + }, + }, + }, + ]; + + const logs: string[] = []; + jest + .spyOn(transports.Console.prototype, 'log') + .mockImplementation((s: any, next: any) => { + logs.push(s.message); + if (next) { + next(); + } + return undefined; + }); + + process.env.LOG_LEVEL = 'info'; + const logger = getRootLogger(); + await loadBackendConfig({ + logger, + argv: [], + additionalConfigs, + additionalSchemas, + }); + logger.info('test'); + logger.info('shouldBeHidden'); + logger.end(); + await new Promise(resolve => { + logger.on('finish', resolve); + }); + + expect(logs[0]).toMatch( + /Found \d+ new secrets in config that will be redacted/, + ); + expect(logs[1]).toEqual('test'); + expect(logs[2]).toEqual('[REDACTED]'); + }, 30000); + }); +}); diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index b452d8db70..b5d1d2b896 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -20,7 +20,10 @@ import { } from '@backstage/backend-app-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { AppConfig, Config } from '@backstage/config'; -import { LoadConfigOptionsRemote } from '@backstage/config-loader'; +import { + ConfigSchemaPackageEntry, + LoadConfigOptionsRemote, +} from '@backstage/config-loader'; import { setRootLoggerRedactionList } from './logging/createRootLogger'; /** @@ -35,11 +38,13 @@ export async function loadBackendConfig(options: { // process.argv or any other overrides remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; + additionalSchemas?: ConfigSchemaPackageEntry[]; argv: string[]; watch?: boolean; }): Promise { const secretEnumerator = await createConfigSecretEnumerator({ logger: options.logger, + additionalSchemas: options.additionalSchemas, }); const { config } = await newLoadBackendConfig(options); diff --git a/packages/backend-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index caf2cf01ec..55a710dbe0 100644 --- a/packages/backend-dynamic-feature-service/api-report.md +++ b/packages/backend-dynamic-feature-service/api-report.md @@ -24,6 +24,7 @@ import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { Router } from 'express'; +import { SchemaDiscoveryService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { TaskRunner } from '@backstage/backend-tasks'; @@ -115,6 +116,11 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory< 'root' >; +// @public (undocumented) +export interface DynamicPluginsSchemaDiscoveryOptions { + schemaLocator?: (platform: PackagePlatform) => string; +} + // @public (undocumented) export const dynamicPluginsServiceFactory: ( options?: DynamicPluginsFactoryOptions | undefined, @@ -220,5 +226,10 @@ export interface ScannedPluginPackage { manifest: ScannedPluginManifest; } +// @public (undocumented) +export const schemaDiscoveryServiceFactory: ( + options?: DynamicPluginsSchemaDiscoveryOptions | undefined, +) => ServiceFactory; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 5a3b88eda6..bc99c2a692 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -32,6 +32,7 @@ "@backstage/cli-common": "workspace:^", "@backstage/cli-node": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", @@ -46,6 +47,7 @@ "@types/express": "^4.17.6", "chokidar": "^3.5.3", "express": "^4.17.1", + "fs-extra": "^7.0.1", "lodash": "^4.17.21", "winston": "^3.2.1" }, @@ -53,7 +55,6 @@ "@backstage/backend-app-api": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/config-loader": "workspace:^", "wait-for-expect": "^3.0.2" }, "files": [ diff --git a/packages/backend-dynamic-feature-service/src/scanner/index.ts b/packages/backend-dynamic-feature-service/src/scanner/index.ts index c5574cc38e..22b476c143 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/index.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/index.ts @@ -15,3 +15,5 @@ */ export type { ScannedPluginManifest, ScannedPluginPackage } from './types'; +export type { DynamicPluginsSchemaDiscoveryOptions } from './plugin-scanner'; +export { schemaDiscoveryServiceFactory } from './plugin-scanner'; diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index af1144f019..7f8996503c 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -22,7 +22,15 @@ import * as path from 'path'; import * as url from 'url'; import debounce from 'lodash/debounce'; import { PackagePlatform, PackageRoles } from '@backstage/cli-node'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { + LoggerService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; +import { findPaths } from '@backstage/cli-common'; +import { gatherDynamicPluginsSchemas } from './schemas'; export interface DynamicPluginScannerOptions { config: Config; @@ -315,3 +323,70 @@ export class PluginScanner { this.untrackChanges(); } } + +/** + * @public + */ +export interface DynamicPluginsSchemaDiscoveryOptions { + /** + * Function that returns the plugin-relative path to the Json schema file for a given platform. + * Default behavior is to look for the `configSchema.json` file in the package `dist` sub-directory. + * + * @param platform - The platform of the plugin. + * @returns the plugin-relative path to the Json schema file. + */ + schemaLocator?: (platform: PackagePlatform) => string; +} + +/** + * @public + */ +export const schemaDiscoveryServiceFactory = createServiceFactory( + (options?: DynamicPluginsSchemaDiscoveryOptions) => ({ + service: schemaDiscoveryServiceRef, + deps: { + config: coreServices.rootConfig, + }, + factory({ config }) { + let schemas: ConfigSchemaPackageEntry[] | undefined; + + return { + async getAdditionalSchemas(): Promise<{ + schemas: Array; + }> { + if (schemas) { + return { + schemas, + }; + } + + const logger = { + ...console, + child() { + return this; + }, + }; + + const scanner = PluginScanner.create({ + config, + logger, + // eslint-disable-next-line no-restricted-syntax + backstageRoot: findPaths(__dirname).targetRoot, + preferAlpha: true, + }); + + const { packages } = await scanner.scanRoot(); + + schemas = await gatherDynamicPluginsSchemas( + packages, + logger, + options?.schemaLocator, + ); + return { + schemas, + }; + }, + }; + }, + }), +); diff --git a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts new file mode 100644 index 0000000000..b6d55181a9 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScannedPluginPackage } from '@backstage/backend-dynamic-feature-service'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; +import fs from 'fs-extra'; +import * as path from 'path'; +import * as url from 'url'; +import { isEmpty } from 'lodash'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { PackagePlatform, PackageRoles } from '@backstage/cli-node'; + +export async function gatherDynamicPluginsSchemas( + packages: ScannedPluginPackage[], + logger: LoggerService, + schemaLocator: (platform: PackagePlatform) => string = () => + path.join('dist', 'configSchema.json'), +): Promise { + const allSchemas: { value: any; path: string }[] = []; + + for (const pluginPackage of packages) { + const platform = PackageRoles.getRoleInfo( + pluginPackage.manifest.backstage.role, + ).platform; + + let pluginLocation = url.fileURLToPath(pluginPackage.location); + if (path.basename(pluginLocation) === 'alpha') { + pluginLocation = path.dirname(pluginLocation); + } + const schemaLocation: string = path.resolve( + pluginLocation, + schemaLocator(platform), + ); + + if (!(await fs.pathExists(schemaLocation))) { + continue; + } + + const serialized = await fs.readJson(schemaLocation); + if (!serialized) { + continue; + } + + if (isEmpty(serialized)) { + continue; + } + + if (!serialized?.$schema || serialized?.type !== 'object') { + logger.error( + `Serialized configuration schema is invalid for plugin ${pluginPackage.manifest.name}`, + ); + continue; + } + + allSchemas.push({ + path: schemaLocation, + value: serialized, + }); + } + + return allSchemas; +} diff --git a/packages/backend-plugin-api/api-report-alpha.md b/packages/backend-plugin-api/api-report-alpha.md index 81b378a671..33b5e748e7 100644 --- a/packages/backend-plugin-api/api-report-alpha.md +++ b/packages/backend-plugin-api/api-report-alpha.md @@ -4,6 +4,7 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import { ServiceRef } from '@backstage/backend-plugin-api'; // @alpha (undocumented) @@ -20,5 +21,19 @@ export const featureDiscoveryServiceRef: ServiceRef< 'root' >; +// @alpha (undocumented) +export interface SchemaDiscoveryService { + // (undocumented) + getAdditionalSchemas(): Promise<{ + schemas: Array; + }>; +} + +// @alpha +export const schemaDiscoveryServiceRef: ServiceRef< + SchemaDiscoveryService, + 'root' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index a2946bd803..5476a75cf3 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -47,6 +47,7 @@ "dependencies": { "@backstage/backend-tasks": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", diff --git a/packages/backend-plugin-api/src/alpha.ts b/packages/backend-plugin-api/src/alpha.ts index baee739f49..e5eac99e4a 100644 --- a/packages/backend-plugin-api/src/alpha.ts +++ b/packages/backend-plugin-api/src/alpha.ts @@ -16,8 +16,11 @@ import { BackendFeature, + coreServices, + createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; /** @alpha */ export interface FeatureDiscoveryService { @@ -33,3 +36,32 @@ export const featureDiscoveryServiceRef = id: 'core.featureDiscovery', scope: 'root', }); + +/** @alpha */ +export interface SchemaDiscoveryService { + getAdditionalSchemas(): Promise<{ schemas: Array }>; +} + +/** + * An optional service that can be used to dynamically load in additional BackendFeatures at runtime. + * @alpha + */ +export const schemaDiscoveryServiceRef = + createServiceRef({ + id: 'core.schemaDiscovery', + scope: 'root', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + config: coreServices.rootConfig, + }, + factory() { + return { + async getAdditionalSchemas() { + return { schemas: [] }; + }, + }; + }, + }), + }); diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 66572dd960..04f1b6a77b 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -45,6 +45,12 @@ export type ConfigSchema = { serialize(): JsonObject; }; +// @public +export type ConfigSchemaPackageEntry = { + value: JsonObject; + path: string; +}; + // @public export type ConfigSchemaProcessingOptions = { visibility?: ConfigVisibility[]; @@ -193,6 +199,7 @@ export type LoadConfigSchemaOptions = ( } ) & { noUndeclaredProperties?: boolean; + additionalSchemas?: ConfigSchemaPackageEntry[]; }; // @public diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index e20e105c7f..80435d6000 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -27,6 +27,7 @@ export type { ConfigVisibility, LoadConfigSchemaOptions, TransformFunc, + ConfigSchemaPackageEntry, } from './schema'; export { loadConfig } from './loader'; export type { diff --git a/packages/config-loader/src/schema/index.ts b/packages/config-loader/src/schema/index.ts index 1dcb9d7b4b..01c18ee905 100644 --- a/packages/config-loader/src/schema/index.ts +++ b/packages/config-loader/src/schema/index.ts @@ -22,4 +22,5 @@ export type { ConfigVisibility, ConfigSchemaProcessingOptions, TransformFunc, + ConfigSchemaPackageEntry, } from './types'; diff --git a/packages/config-loader/src/schema/load.test.ts b/packages/config-loader/src/schema/load.test.ts index 525565f3d5..107f8d1f3b 100644 --- a/packages/config-loader/src/schema/load.test.ts +++ b/packages/config-loader/src/schema/load.test.ts @@ -126,6 +126,60 @@ describe('loadConfigSchema', () => { ); }); + it('should include additional schemas', async () => { + const schema = await loadConfigSchema({ + serialized: { + backstageConfigSchemaVersion: 1, + schemas: [ + { + path: 'mainSchema', + value: { + type: 'object', + properties: { + key1: { type: 'string', visibility: 'frontend' }, + }, + }, + }, + ], + }, + additionalSchemas: [ + { + path: 'additionalSchema', + value: { + type: 'object', + properties: { + additionalKey: { type: 'string', visibility: 'frontend' }, + }, + }, + }, + ], + }); + + expect(schema.serialize()).toEqual({ + backstageConfigSchemaVersion: 1, + schemas: [ + { + path: 'mainSchema', + value: { + type: 'object', + properties: { + key1: { type: 'string', visibility: 'frontend' }, + }, + }, + }, + { + path: 'additionalSchema', + value: { + type: 'object', + properties: { + additionalKey: { type: 'string', visibility: 'frontend' }, + }, + }, + }, + ], + }); + }); + describe('should consider schema', () => { it('when filtering simple config', async () => { mockDir.setContent({ diff --git a/packages/config-loader/src/schema/load.ts b/packages/config-loader/src/schema/load.ts index 885da44116..5c535d1ff6 100644 --- a/packages/config-loader/src/schema/load.ts +++ b/packages/config-loader/src/schema/load.ts @@ -43,6 +43,7 @@ export type LoadConfigSchemaOptions = } ) & { noUndeclaredProperties?: boolean; + additionalSchemas?: ConfigSchemaPackageEntry[]; }; function errorsToError(errors: ValidationError[]): Error { @@ -83,6 +84,7 @@ export async function loadConfigSchema( } schemas = serialized.schemas as ConfigSchemaPackageEntry[]; } + schemas.push(...(options.additionalSchemas || [])); const validate = compileConfigSchemas(schemas, { noUndeclaredProperties: options.noUndeclaredProperties, diff --git a/packages/config-loader/src/schema/types.ts b/packages/config-loader/src/schema/types.ts index 8f679c93fc..66e77018ee 100644 --- a/packages/config-loader/src/schema/types.ts +++ b/packages/config-loader/src/schema/types.ts @@ -18,7 +18,9 @@ import { AppConfig } from '@backstage/config'; import { JsonObject } from '@backstage/types'; /** - * An sub-set of configuration schema. + * A sub-set of configuration schema for a given package. + * + * @public */ export type ConfigSchemaPackageEntry = { /** diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index 0383129823..9ea42d2fbd 100644 --- a/plugins/app-backend/api-report.md +++ b/plugins/app-backend/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import express from 'express'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -13,6 +14,7 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { + additionalSchemas?: ConfigSchemaPackageEntry[]; appPackageName: string; // (undocumented) config: Config; diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index 63912df5c4..0e223c3650 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -20,6 +20,7 @@ import { Logger } from 'winston'; import { AppConfig, Config } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; type InjectOptions = { appConfigs: AppConfig[]; @@ -74,6 +75,7 @@ type ReadOptions = { env: { [name: string]: string | undefined }; appDistDir: string; config: Config; + additionalSchemas?: ConfigSchemaPackageEntry[]; }; /** @@ -90,7 +92,10 @@ export async function readConfigs(options: ReadOptions): Promise { const serializedSchema = await fs.readJson(schemaPath); try { - const schema = await loadConfigSchema({ serialized: serializedSchema }); + const schema = await loadConfigSchema({ + serialized: serializedSchema, + additionalSchemas: options.additionalSchemas, + }); const frontendConfigs = await schema.process( [{ data: config.get() as JsonObject, context: 'app' }], diff --git a/plugins/app-backend/src/service/appPlugin.ts b/plugins/app-backend/src/service/appPlugin.ts index 9ba2ea6d6c..07616e832f 100644 --- a/plugins/app-backend/src/service/appPlugin.ts +++ b/plugins/app-backend/src/service/appPlugin.ts @@ -19,6 +19,7 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { createRouter } from './router'; import { loggerToWinstonLogger } from '@backstage/backend-common'; import { staticFallbackHandlerExtensionPoint } from '@backstage/plugin-app-node'; @@ -49,8 +50,9 @@ export const appPlugin = createBackendPlugin({ config: coreServices.rootConfig, database: coreServices.database, httpRouter: coreServices.httpRouter, + schemaDiscovery: schemaDiscoveryServiceRef, }, - async init({ logger, config, database, httpRouter }) { + async init({ logger, config, database, httpRouter, schemaDiscovery }) { const appPackageName = config.getOptionalString('app.packageName') ?? 'app'; const winstonLogger = loggerToWinstonLogger(logger); @@ -61,6 +63,9 @@ export const appPlugin = createBackendPlugin({ database, appPackageName, staticFallbackHandler, + additionalSchemas: ( + await schemaDiscovery?.getAdditionalSchemas() + )?.schemas, }); httpRouter.use(router); }, diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 5574892d37..8e4750dcef 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -37,6 +37,7 @@ import { CACHE_CONTROL_NO_CACHE, CACHE_CONTROL_REVALIDATE_CACHE, } from '../lib/headers'; +import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -85,6 +86,17 @@ export interface RouterOptions { * This also disables configuration injection though `APP_CONFIG_` environment variables. */ disableConfigInjection?: boolean; + + /** + * + * Provides a list of additional config schemas, in addition to the serialized schemas + * generated during the application build. + * This is useful when additional plugins are dynamically loaded in the application at start, + * which were not part of the application build. This option allows feeding the corresponding + * JSON schemas. + * + */ + additionalSchemas?: ConfigSchemaPackageEntry[]; } /** @public */ @@ -121,6 +133,7 @@ export async function createRouter( config, appDistDir, env: process.env, + additionalSchemas: options.additionalSchemas, }); injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir }); diff --git a/yarn.lock b/yarn.lock index 5f84cbbc51..0e32adda8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3395,6 +3395,7 @@ __metadata: "@types/express": ^4.17.6 chokidar: ^3.5.3 express: ^4.17.1 + fs-extra: ^7.0.1 lodash: ^4.17.21 wait-for-expect: ^3.0.2 winston: ^3.2.1 @@ -3428,6 +3429,7 @@ __metadata: "@backstage/backend-tasks": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/config-loader": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/types": "workspace:^" From 62ba5f404436e72591864ee17d95a290cd96608f Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 22 Jan 2024 15:37:36 +0100 Subject: [PATCH 2/8] Fix review comments: more information in `schemaLocator` Signed-off-by: David Festal --- .../api-report.md | 2 +- .../src/scanner/plugin-scanner.ts | 12 ++++++----- .../src/scanner/schemas.ts | 20 ++++++++----------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/backend-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index 55a710dbe0..5a9e93f50b 100644 --- a/packages/backend-dynamic-feature-service/api-report.md +++ b/packages/backend-dynamic-feature-service/api-report.md @@ -118,7 +118,7 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory< // @public (undocumented) export interface DynamicPluginsSchemaDiscoveryOptions { - schemaLocator?: (platform: PackagePlatform) => string; + schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; } // @public (undocumented) diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index 7f8996503c..0c6ad90e52 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -329,13 +329,15 @@ export class PluginScanner { */ export interface DynamicPluginsSchemaDiscoveryOptions { /** - * Function that returns the plugin-relative path to the Json schema file for a given platform. - * Default behavior is to look for the `configSchema.json` file in the package `dist` sub-directory. + * Function that returns the path to the Json schema file for a given scanned plugin package. + * The path is either absolute, or relative to the plugin package root directory. * - * @param platform - The platform of the plugin. - * @returns the plugin-relative path to the Json schema file. + * Default behavior is to look for the `dist/configSchema.json` relative path. + * + * @param pluginPackage - The scanned plugin package. + * @returns the absolute or plugin-relative path to the Json schema file. */ - schemaLocator?: (platform: PackagePlatform) => string; + schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; } /** diff --git a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts index b6d55181a9..ebb456e63b 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts @@ -21,29 +21,25 @@ import * as path from 'path'; import * as url from 'url'; import { isEmpty } from 'lodash'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { PackagePlatform, PackageRoles } from '@backstage/cli-node'; export async function gatherDynamicPluginsSchemas( packages: ScannedPluginPackage[], logger: LoggerService, - schemaLocator: (platform: PackagePlatform) => string = () => + schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => path.join('dist', 'configSchema.json'), ): Promise { const allSchemas: { value: any; path: string }[] = []; for (const pluginPackage of packages) { - const platform = PackageRoles.getRoleInfo( - pluginPackage.manifest.backstage.role, - ).platform; + let schemaLocation = schemaLocator(pluginPackage); - let pluginLocation = url.fileURLToPath(pluginPackage.location); - if (path.basename(pluginLocation) === 'alpha') { - pluginLocation = path.dirname(pluginLocation); + if (!path.isAbsolute(schemaLocation)) { + let pluginLocation = url.fileURLToPath(pluginPackage.location); + if (path.basename(pluginLocation) === 'alpha') { + pluginLocation = path.dirname(pluginLocation); + } + schemaLocation = path.resolve(pluginLocation, schemaLocation); } - const schemaLocation: string = path.resolve( - pluginLocation, - schemaLocator(platform), - ); if (!(await fs.pathExists(schemaLocation))) { continue; From 54ad8e1553a1b89dbb5ce97ffd94edf91bd77206 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 22 Jan 2024 18:43:41 +0100 Subject: [PATCH 3/8] Fix review comment: split changesets... and use `patch` instead of `minor` since there are no breaking changes. Signed-off-by: David Festal --- .changeset/late-poets-love.md | 6 ++++++ .changeset/nine-forks-sell.md | 6 ++++++ .changeset/rich-bees-fry.md | 5 +++++ .changeset/swift-pumpkins-shake.md | 9 +-------- 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 .changeset/late-poets-love.md create mode 100644 .changeset/nine-forks-sell.md create mode 100644 .changeset/rich-bees-fry.md diff --git a/.changeset/late-poets-love.md b/.changeset/late-poets-love.md new file mode 100644 index 0000000000..664c665a0d --- /dev/null +++ b/.changeset/late-poets-love.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-app-api': patch +'@backstage/backend-common': patch +--- + +Make the root logger use the additional configuration schemas when hiding secrets. diff --git a/.changeset/nine-forks-sell.md b/.changeset/nine-forks-sell.md new file mode 100644 index 0000000000..9886be0022 --- /dev/null +++ b/.changeset/nine-forks-sell.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/config-loader': patch +--- + +Introduce a way to provide additional configuration schemas at runtime (application start). diff --git a/.changeset/rich-bees-fry.md b/.changeset/rich-bees-fry.md new file mode 100644 index 0000000000..841ae985fc --- /dev/null +++ b/.changeset/rich-bees-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': minor +--- + +Make the `app-backend` plugin use additional configuration schemas. diff --git a/.changeset/swift-pumpkins-shake.md b/.changeset/swift-pumpkins-shake.md index c9441a3690..231039d1d8 100644 --- a/.changeset/swift-pumpkins-shake.md +++ b/.changeset/swift-pumpkins-shake.md @@ -1,12 +1,5 @@ --- -'@backstage/backend-app-api': minor -'@backstage/backend-common': minor -'@backstage/config-loader': minor -'@backstage/plugin-app-backend': minor '@backstage/backend-dynamic-feature-service': minor --- -Allow referencing additional configuration schemas at runtime (application start), and have them taken in account where schemas are used: - -- the backed-app plugin, in order to prepare the frontend configuration -- the backend application loggers, in order to hide secret configuration values (defined in the schemas). +Implement the discovery of additional configuration schemas for dynamic plugins. From 84fcbc49d6d444bcfb347886ed8ca33cb139954b Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 6 Feb 2024 16:43:27 +0100 Subject: [PATCH 4/8] Fix review comment: don't use `ConfigSchemaPackageEntry` nor make it public. Signed-off-by: David Festal --- packages/backend-app-api/api-report.md | 5 ++-- packages/backend-app-api/src/config/config.ts | 4 +-- .../rootLoggerServiceFactory.test.ts | 28 ++++++++----------- packages/backend-common/api-report.md | 6 ++-- packages/backend-common/src/config.test.ts | 19 ++++++------- packages/backend-common/src/config.ts | 8 ++---- .../src/scanner/plugin-scanner.ts | 6 ++-- .../src/scanner/schemas.ts | 11 +++----- .../backend-plugin-api/api-report-alpha.md | 6 ++-- packages/backend-plugin-api/src/alpha.ts | 8 ++++-- packages/config-loader/api-report.md | 10 ++----- packages/config-loader/src/index.ts | 1 - .../config-loader/src/schema/load.test.ts | 15 ++++------ packages/config-loader/src/schema/load.ts | 13 +++++++-- packages/config-loader/src/schema/types.ts | 4 +-- plugins/app-backend/api-report.md | 6 ++-- plugins/app-backend/src/lib/config.ts | 3 +- plugins/app-backend/src/service/router.ts | 6 ++-- 18 files changed, 76 insertions(+), 83 deletions(-) diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 1143beec15..cff8c3c0cd 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -9,7 +9,6 @@ import type { AppConfig } from '@backstage/config'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CacheClient } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import { CorsOptions } from 'cors'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { ErrorRequestHandler } from 'express'; @@ -65,7 +64,9 @@ export const cacheServiceFactory: () => ServiceFactory; export function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { + [context: string]: JsonObject; + }; }): Promise<(config: Config) => Iterable>; // @public diff --git a/packages/backend-app-api/src/config/config.ts b/packages/backend-app-api/src/config/config.ts index d4eb5fccb9..abca385e62 100644 --- a/packages/backend-app-api/src/config/config.ts +++ b/packages/backend-app-api/src/config/config.ts @@ -23,19 +23,19 @@ import { loadConfig, ConfigTarget, LoadConfigOptionsRemote, - ConfigSchemaPackageEntry, } from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import type { Config, AppConfig } from '@backstage/config'; import { getPackages } from '@manypkg/get-packages'; import { ObservableConfigProxy } from './ObservableConfigProxy'; import { isValidUrl } from '../lib/urls'; +import { JsonObject } from '@backstage/types'; /** @public */ export async function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { [context: string]: JsonObject }; }): Promise<(config: Config) => Iterable> { const { logger, dir = process.cwd() } = options; const { packages } = await getPackages(dir); diff --git a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts index ac7b9e8cf2..b840822c6f 100644 --- a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts @@ -22,15 +22,12 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; -import { - ConfigSchemaPackageEntry, - ConfigSources, - StaticConfigSource, -} from '@backstage/config-loader'; +import { ConfigSources, StaticConfigSource } from '@backstage/config-loader'; import { transports } from 'winston'; import { rootLifecycleServiceFactory } from '../rootLifecycle'; import { lifecycleServiceFactory } from '../lifecycle'; import { loggerServiceFactory } from '../logger'; +import { JsonObject } from '@backstage/types'; describe('rootLogger', () => { describe('rootLoggerServiceFactory', () => { @@ -46,20 +43,17 @@ describe('rootLogger', () => { }, }; - const additionalSchemas = [ - { - path: 'test', - value: { - type: 'object', - properties: { - secretValue: { - type: 'string', - visibility: 'secret', - }, + const additionalSchemas = { + test: { + type: 'object', + properties: { + secretValue: { + type: 'string', + visibility: 'secret', }, }, }, - ]; + }; const logs: string[] = []; jest @@ -97,7 +91,7 @@ describe('rootLogger', () => { }, factory: async () => ({ getAdditionalSchemas: async (): Promise<{ - schemas: Array; + schemas: { [context: string]: JsonObject }; }> => ({ schemas: additionalSchemas, }), diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 7a3fc72a5b..607eaa010e 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -20,7 +20,6 @@ import { CacheService as CacheClient } from '@backstage/backend-plugin-api'; import { CacheServiceOptions as CacheClientOptions } from '@backstage/backend-plugin-api'; import { CacheServiceSetOptions as CacheClientSetOptions } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import cors from 'cors'; import Docker from 'dockerode'; import { ErrorRequestHandler } from 'express'; @@ -33,6 +32,7 @@ import { GitLabIntegration } from '@backstage/integration'; import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api'; import { IdentityService } from '@backstage/backend-plugin-api'; import { isChildPath } from '@backstage/cli-common'; +import { JsonObject } from '@backstage/types'; import { Knex } from 'knex'; import knexFactory from 'knex'; import { KubeConfig } from '@kubernetes/client-node'; @@ -561,7 +561,9 @@ export function loadBackendConfig(options: { logger: LoggerService; remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { + [context: string]: JsonObject; + }; argv: string[]; watch?: boolean; }): Promise; diff --git a/packages/backend-common/src/config.test.ts b/packages/backend-common/src/config.test.ts index 931c0b5c84..05d1cbc3ec 100644 --- a/packages/backend-common/src/config.test.ts +++ b/packages/backend-common/src/config.test.ts @@ -36,20 +36,17 @@ describe('config', () => { }, ]; - const additionalSchemas = [ - { - path: 'test', - value: { - type: 'object', - properties: { - secretValue: { - type: 'string', - visibility: 'secret', - }, + const additionalSchemas = { + test: { + type: 'object', + properties: { + secretValue: { + type: 'string', + visibility: 'secret', }, }, }, - ]; + }; const logs: string[] = []; jest diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index b5d1d2b896..ac89f3f4ec 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -20,11 +20,9 @@ import { } from '@backstage/backend-app-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { AppConfig, Config } from '@backstage/config'; -import { - ConfigSchemaPackageEntry, - LoadConfigOptionsRemote, -} from '@backstage/config-loader'; +import { LoadConfigOptionsRemote } from '@backstage/config-loader'; import { setRootLoggerRedactionList } from './logging/createRootLogger'; +import { JsonObject } from '@backstage/types'; /** * Load configuration for a Backend. @@ -38,7 +36,7 @@ export async function loadBackendConfig(options: { // process.argv or any other overrides remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { [context: string]: JsonObject }; argv: string[]; watch?: boolean; }): Promise { diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index 0c6ad90e52..a9e11ca60b 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -28,9 +28,9 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import { findPaths } from '@backstage/cli-common'; import { gatherDynamicPluginsSchemas } from './schemas'; +import { JsonObject } from '@backstage/types'; export interface DynamicPluginScannerOptions { config: Config; @@ -350,11 +350,11 @@ export const schemaDiscoveryServiceFactory = createServiceFactory( config: coreServices.rootConfig, }, factory({ config }) { - let schemas: ConfigSchemaPackageEntry[] | undefined; + let schemas: { [context: string]: JsonObject } | undefined; return { async getAdditionalSchemas(): Promise<{ - schemas: Array; + schemas: { [context: string]: JsonObject }; }> { if (schemas) { return { diff --git a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts index ebb456e63b..f00828859a 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts @@ -15,20 +15,20 @@ */ import { ScannedPluginPackage } from '@backstage/backend-dynamic-feature-service'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import fs from 'fs-extra'; import * as path from 'path'; import * as url from 'url'; import { isEmpty } from 'lodash'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { JsonObject } from '@backstage/types'; export async function gatherDynamicPluginsSchemas( packages: ScannedPluginPackage[], logger: LoggerService, schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => path.join('dist', 'configSchema.json'), -): Promise { - const allSchemas: { value: any; path: string }[] = []; +): Promise<{ [context: string]: JsonObject }> { + const allSchemas: { [context: string]: JsonObject } = {}; for (const pluginPackage of packages) { let schemaLocation = schemaLocator(pluginPackage); @@ -61,10 +61,7 @@ export async function gatherDynamicPluginsSchemas( continue; } - allSchemas.push({ - path: schemaLocation, - value: serialized, - }); + allSchemas[schemaLocation] = serialized; } return allSchemas; diff --git a/packages/backend-plugin-api/api-report-alpha.md b/packages/backend-plugin-api/api-report-alpha.md index 33b5e748e7..0d9eaf4a0e 100644 --- a/packages/backend-plugin-api/api-report-alpha.md +++ b/packages/backend-plugin-api/api-report-alpha.md @@ -4,7 +4,7 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; +import { JsonObject } from '@backstage/types'; import { ServiceRef } from '@backstage/backend-plugin-api'; // @alpha (undocumented) @@ -25,7 +25,9 @@ export const featureDiscoveryServiceRef: ServiceRef< export interface SchemaDiscoveryService { // (undocumented) getAdditionalSchemas(): Promise<{ - schemas: Array; + schemas: { + [context: string]: JsonObject; + }; }>; } diff --git a/packages/backend-plugin-api/src/alpha.ts b/packages/backend-plugin-api/src/alpha.ts index e5eac99e4a..7d7e3177c2 100644 --- a/packages/backend-plugin-api/src/alpha.ts +++ b/packages/backend-plugin-api/src/alpha.ts @@ -20,7 +20,7 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; +import { JsonObject } from '@backstage/types'; /** @alpha */ export interface FeatureDiscoveryService { @@ -39,7 +39,9 @@ export const featureDiscoveryServiceRef = /** @alpha */ export interface SchemaDiscoveryService { - getAdditionalSchemas(): Promise<{ schemas: Array }>; + getAdditionalSchemas(): Promise<{ + schemas: { [context: string]: JsonObject }; + }>; } /** @@ -59,7 +61,7 @@ export const schemaDiscoveryServiceRef = factory() { return { async getAdditionalSchemas() { - return { schemas: [] }; + return { schemas: {} }; }, }; }, diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 04f1b6a77b..c3fc9f522e 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -45,12 +45,6 @@ export type ConfigSchema = { serialize(): JsonObject; }; -// @public -export type ConfigSchemaPackageEntry = { - value: JsonObject; - path: string; -}; - // @public export type ConfigSchemaProcessingOptions = { visibility?: ConfigVisibility[]; @@ -199,7 +193,9 @@ export type LoadConfigSchemaOptions = ( } ) & { noUndeclaredProperties?: boolean; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { + [context: string]: JsonObject; + }; }; // @public diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 80435d6000..e20e105c7f 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -27,7 +27,6 @@ export type { ConfigVisibility, LoadConfigSchemaOptions, TransformFunc, - ConfigSchemaPackageEntry, } from './schema'; export { loadConfig } from './loader'; export type { diff --git a/packages/config-loader/src/schema/load.test.ts b/packages/config-loader/src/schema/load.test.ts index 107f8d1f3b..f9299340c3 100644 --- a/packages/config-loader/src/schema/load.test.ts +++ b/packages/config-loader/src/schema/load.test.ts @@ -142,17 +142,14 @@ describe('loadConfigSchema', () => { }, ], }, - additionalSchemas: [ - { - path: 'additionalSchema', - value: { - type: 'object', - properties: { - additionalKey: { type: 'string', visibility: 'frontend' }, - }, + additionalSchemas: { + additionalSchema: { + type: 'object', + properties: { + additionalKey: { type: 'string', visibility: 'frontend' }, }, }, - ], + }, }); expect(schema.serialize()).toEqual({ diff --git a/packages/config-loader/src/schema/load.ts b/packages/config-loader/src/schema/load.ts index 5c535d1ff6..03b85b2756 100644 --- a/packages/config-loader/src/schema/load.ts +++ b/packages/config-loader/src/schema/load.ts @@ -43,7 +43,7 @@ export type LoadConfigSchemaOptions = } ) & { noUndeclaredProperties?: boolean; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { [context: string]: JsonObject }; }; function errorsToError(errors: ValidationError[]): Error { @@ -84,7 +84,16 @@ export async function loadConfigSchema( } schemas = serialized.schemas as ConfigSchemaPackageEntry[]; } - schemas.push(...(options.additionalSchemas || [])); + if (options.additionalSchemas) { + schemas.push( + ...Object.keys(options.additionalSchemas).map(context => { + return { + path: context, + value: options.additionalSchemas![context], + }; + }), + ); + } const validate = compileConfigSchemas(schemas, { noUndeclaredProperties: options.noUndeclaredProperties, diff --git a/packages/config-loader/src/schema/types.ts b/packages/config-loader/src/schema/types.ts index 66e77018ee..8f679c93fc 100644 --- a/packages/config-loader/src/schema/types.ts +++ b/packages/config-loader/src/schema/types.ts @@ -18,9 +18,7 @@ import { AppConfig } from '@backstage/config'; import { JsonObject } from '@backstage/types'; /** - * A sub-set of configuration schema for a given package. - * - * @public + * An sub-set of configuration schema. */ export type ConfigSchemaPackageEntry = { /** diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index 9ea42d2fbd..fd85b68a87 100644 --- a/plugins/app-backend/api-report.md +++ b/plugins/app-backend/api-report.md @@ -4,8 +4,8 @@ ```ts import { Config } from '@backstage/config'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; import express from 'express'; +import { JsonObject } from '@backstage/types'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -14,7 +14,9 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { + [context: string]: JsonObject; + }; appPackageName: string; // (undocumented) config: Config; diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index 0e223c3650..dc041a2593 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -20,7 +20,6 @@ import { Logger } from 'winston'; import { AppConfig, Config } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; type InjectOptions = { appConfigs: AppConfig[]; @@ -75,7 +74,7 @@ type ReadOptions = { env: { [name: string]: string | undefined }; appDistDir: string; config: Config; - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { [context: string]: JsonObject }; }; /** diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 8e4750dcef..204d5df26a 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -37,7 +37,7 @@ import { CACHE_CONTROL_NO_CACHE, CACHE_CONTROL_REVALIDATE_CACHE, } from '../lib/headers'; -import { ConfigSchemaPackageEntry } from '@backstage/config-loader'; +import { JsonObject } from '@backstage/types'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -89,14 +89,14 @@ export interface RouterOptions { /** * - * Provides a list of additional config schemas, in addition to the serialized schemas + * Provides a map of additional config schemas, in addition to the serialized schemas * generated during the application build. * This is useful when additional plugins are dynamically loaded in the application at start, * which were not part of the application build. This option allows feeding the corresponding * JSON schemas. * */ - additionalSchemas?: ConfigSchemaPackageEntry[]; + additionalSchemas?: { [context: string]: JsonObject }; } /** @public */ From 2fb527dd34dab57917005d6e0b51a1c5332d4f99 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 12 Feb 2024 19:13:01 +0100 Subject: [PATCH 5/8] Fix review comments: remove `schemaDiscovery` service Signed-off-by: David Festal --- .changeset/late-poets-love.md | 3 +- .changeset/nine-forks-sell.md | 6 - .changeset/rich-bees-fry.md | 3 +- .changeset/swift-pumpkins-shake.md | 5 +- packages/backend-app-api/api-report.md | 5 +- packages/backend-app-api/src/config/config.ts | 13 +- .../rootLoggerServiceFactory.test.ts | 128 ------------ .../rootLogger/rootLoggerServiceFactory.ts | 11 +- packages/backend-common/api-report.md | 4 - packages/backend-common/src/config.test.ts | 84 -------- packages/backend-common/src/config.ts | 3 - .../api-report.md | 32 ++- .../package.json | 19 +- .../src/index.ts | 1 + .../src/scanner/index.ts | 2 - .../src/scanner/plugin-scanner.ts | 79 +------- .../src/scanner/schemas.ts | 68 ------- .../src/schemas/appBackendModule.ts | 53 +++++ .../src/schemas/index.ts | 24 +++ .../src/schemas/rootLoggerServiceFactory.ts | 63 ++++++ .../src/schemas/schemas.ts | 187 ++++++++++++++++++ .../backend-plugin-api/api-report-alpha.md | 17 -- packages/backend-plugin-api/package.json | 1 - packages/backend-plugin-api/src/alpha.ts | 34 ---- packages/config-loader/api-report.md | 3 - packages/config-loader/src/schema/index.ts | 1 - .../config-loader/src/schema/load.test.ts | 51 ----- packages/config-loader/src/schema/load.ts | 11 -- plugins/app-backend/api-report.md | 6 +- plugins/app-backend/src/lib/config.ts | 17 +- plugins/app-backend/src/service/appPlugin.ts | 27 ++- plugins/app-backend/src/service/router.ts | 12 +- plugins/app-node/api-report.md | 14 ++ plugins/app-node/package.json | 4 +- plugins/app-node/src/extensions.ts | 23 +++ plugins/app-node/src/index.ts | 4 + plugins/app-node/src/schema.ts | 39 ++++ yarn.lock | 7 +- 38 files changed, 513 insertions(+), 551 deletions(-) delete mode 100644 .changeset/nine-forks-sell.md delete mode 100644 packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts delete mode 100644 packages/backend-common/src/config.test.ts delete mode 100644 packages/backend-dynamic-feature-service/src/scanner/schemas.ts create mode 100644 packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts create mode 100644 packages/backend-dynamic-feature-service/src/schemas/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts create mode 100644 packages/backend-dynamic-feature-service/src/schemas/schemas.ts create mode 100644 plugins/app-node/src/schema.ts diff --git a/.changeset/late-poets-love.md b/.changeset/late-poets-love.md index 664c665a0d..bd6692560d 100644 --- a/.changeset/late-poets-love.md +++ b/.changeset/late-poets-love.md @@ -1,6 +1,5 @@ --- '@backstage/backend-app-api': patch -'@backstage/backend-common': patch --- -Make the root logger use the additional configuration schemas when hiding secrets. +Allow the `createConfigSecretEnumerator` to take an optional `configSchema` argument with an already-loaded global configuration schema. diff --git a/.changeset/nine-forks-sell.md b/.changeset/nine-forks-sell.md deleted file mode 100644 index 9886be0022..0000000000 --- a/.changeset/nine-forks-sell.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-plugin-api': patch -'@backstage/config-loader': patch ---- - -Introduce a way to provide additional configuration schemas at runtime (application start). diff --git a/.changeset/rich-bees-fry.md b/.changeset/rich-bees-fry.md index 841ae985fc..3dcfa95532 100644 --- a/.changeset/rich-bees-fry.md +++ b/.changeset/rich-bees-fry.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-app-backend': minor +'@backstage/plugin-app-node': minor --- -Make the `app-backend` plugin use additional configuration schemas. +Allow the `app-backend` plugin to use a global configuration schema provided externally through an extension. diff --git a/.changeset/swift-pumpkins-shake.md b/.changeset/swift-pumpkins-shake.md index 231039d1d8..c7a0a44ade 100644 --- a/.changeset/swift-pumpkins-shake.md +++ b/.changeset/swift-pumpkins-shake.md @@ -2,4 +2,7 @@ '@backstage/backend-dynamic-feature-service': minor --- -Implement the discovery of additional configuration schemas for dynamic plugins. +Implement the discovery of additional individual configuration schemas for dynamic plugins, and provide: + +- an alternate implementation of the root logger service that takes them into account, +- an extension to the App backend plugin to set a global configuration schema that takes them into account. diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index cff8c3c0cd..f41a38de7b 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -9,6 +9,7 @@ import type { AppConfig } from '@backstage/config'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CacheClient } from '@backstage/backend-common'; import { Config } from '@backstage/config'; +import { ConfigSchema } from '@backstage/config-loader'; import { CorsOptions } from 'cors'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { ErrorRequestHandler } from 'express'; @@ -64,9 +65,7 @@ export const cacheServiceFactory: () => ServiceFactory; export function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; - additionalSchemas?: { - [context: string]: JsonObject; - }; + schema?: ConfigSchema; }): Promise<(config: Config) => Iterable>; // @public diff --git a/packages/backend-app-api/src/config/config.ts b/packages/backend-app-api/src/config/config.ts index abca385e62..8f98c5597c 100644 --- a/packages/backend-app-api/src/config/config.ts +++ b/packages/backend-app-api/src/config/config.ts @@ -23,26 +23,27 @@ import { loadConfig, ConfigTarget, LoadConfigOptionsRemote, + ConfigSchema, } from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import type { Config, AppConfig } from '@backstage/config'; import { getPackages } from '@manypkg/get-packages'; import { ObservableConfigProxy } from './ObservableConfigProxy'; import { isValidUrl } from '../lib/urls'; -import { JsonObject } from '@backstage/types'; /** @public */ export async function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; - additionalSchemas?: { [context: string]: JsonObject }; + schema?: ConfigSchema; }): Promise<(config: Config) => Iterable> { const { logger, dir = process.cwd() } = options; const { packages } = await getPackages(dir); - const schema = await loadConfigSchema({ - dependencies: packages.map(p => p.packageJson.name), - additionalSchemas: options.additionalSchemas, - }); + const schema = + options.schema ?? + (await loadConfigSchema({ + dependencies: packages.map(p => p.packageJson.name).filter(() => false), + })); return (config: Config) => { const [secretsData] = schema.process( diff --git a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts deleted file mode 100644 index b840822c6f..0000000000 --- a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createSpecializedBackend } from '../../../wiring'; -import { rootLoggerServiceFactory } from './rootLoggerServiceFactory'; -import { - coreServices, - createBackendPlugin, - createServiceFactory, -} from '@backstage/backend-plugin-api'; -import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; -import { ConfigSources, StaticConfigSource } from '@backstage/config-loader'; -import { transports } from 'winston'; -import { rootLifecycleServiceFactory } from '../rootLifecycle'; -import { lifecycleServiceFactory } from '../lifecycle'; -import { loggerServiceFactory } from '../logger'; -import { JsonObject } from '@backstage/types'; - -describe('rootLogger', () => { - describe('rootLoggerServiceFactory', () => { - afterEach(() => { - jest.resetModules(); - }); - - it('also hide secret config values coming from additional schemas', async () => { - const additionalConfigs = { - context: 'test', - data: { - secretValue: 'shouldBeHidden', - }, - }; - - const additionalSchemas = { - test: { - type: 'object', - properties: { - secretValue: { - type: 'string', - visibility: 'secret', - }, - }, - }, - }; - - const logs: string[] = []; - jest - .spyOn(transports.Console.prototype, 'log') - .mockImplementation((s: any, next: any) => { - logs.push(s.message); - if (next) { - next(); - } - return undefined; - }); - - const backend = createSpecializedBackend({ - defaultServiceFactories: [ - lifecycleServiceFactory(), - loggerServiceFactory(), - rootLifecycleServiceFactory(), - rootLoggerServiceFactory(), - createServiceFactory({ - service: coreServices.rootConfig, - deps: {}, - async factory() { - return await ConfigSources.toConfig( - ConfigSources.merge([ - ConfigSources.default({}), - StaticConfigSource.create(additionalConfigs), - ]), - ); - }, - }), - createServiceFactory({ - service: schemaDiscoveryServiceRef, - deps: { - config: coreServices.rootConfig, - }, - factory: async () => ({ - getAdditionalSchemas: async (): Promise<{ - schemas: { [context: string]: JsonObject }; - }> => ({ - schemas: additionalSchemas, - }), - }), - }), - ], - }); - backend.add( - createBackendPlugin({ - pluginId: 'test', - register(env) { - env.registerInit({ - deps: { - logger: coreServices.logger, - lifecycle: coreServices.lifecycle, - }, - async init({ logger }) { - logger.info('test'); - logger.info('shouldBeHidden'); - }, - }); - }, - }), - ); - await backend.start(); - - expect(logs[0]).toMatch( - /Found \d+ new secrets in config that will be redacted/, - ); - expect(logs[1]).toEqual('test'); - expect(logs[2]).toEqual('[REDACTED]'); - }, 30000); - }); -}); diff --git a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts index becf618f4f..bd45df383f 100644 --- a/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/rootLogger/rootLoggerServiceFactory.ts @@ -18,7 +18,6 @@ import { createServiceFactory, coreServices, } from '@backstage/backend-plugin-api'; -import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { WinstonLogger } from '../../../logging'; import { transports, format } from 'winston'; import { createConfigSecretEnumerator } from '../../../config'; @@ -28,9 +27,8 @@ export const rootLoggerServiceFactory = createServiceFactory({ service: coreServices.rootLogger, deps: { config: coreServices.rootConfig, - schemaDiscovery: schemaDiscoveryServiceRef, }, - async factory({ config, schemaDiscovery }) { + async factory({ config }) { const logger = WinstonLogger.create({ meta: { service: 'backstage', @@ -43,12 +41,7 @@ export const rootLoggerServiceFactory = createServiceFactory({ transports: [new transports.Console()], }); - const secretEnumerator = await createConfigSecretEnumerator({ - logger, - additionalSchemas: ( - await schemaDiscovery?.getAdditionalSchemas() - )?.schemas, - }); + const secretEnumerator = await createConfigSecretEnumerator({ logger }); logger.addRedactions(secretEnumerator(config)); config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 607eaa010e..e4a1ecfdf1 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -32,7 +32,6 @@ import { GitLabIntegration } from '@backstage/integration'; import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api'; import { IdentityService } from '@backstage/backend-plugin-api'; import { isChildPath } from '@backstage/cli-common'; -import { JsonObject } from '@backstage/types'; import { Knex } from 'knex'; import knexFactory from 'knex'; import { KubeConfig } from '@kubernetes/client-node'; @@ -561,9 +560,6 @@ export function loadBackendConfig(options: { logger: LoggerService; remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; - additionalSchemas?: { - [context: string]: JsonObject; - }; argv: string[]; watch?: boolean; }): Promise; diff --git a/packages/backend-common/src/config.test.ts b/packages/backend-common/src/config.test.ts deleted file mode 100644 index 05d1cbc3ec..0000000000 --- a/packages/backend-common/src/config.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { transports } from 'winston'; -import { loadBackendConfig } from './config'; -import { getRootLogger } from './logging'; - -describe('config', () => { - describe('loadBackendConfig', () => { - const env = process.env; - afterEach(() => { - jest.resetModules(); - process.env = env; - }); - - it('also hide secret config values coming from additional schemas', async () => { - const additionalConfigs = [ - { - context: 'test', - data: { - secretValue: 'shouldBeHidden', - }, - }, - ]; - - const additionalSchemas = { - test: { - type: 'object', - properties: { - secretValue: { - type: 'string', - visibility: 'secret', - }, - }, - }, - }; - - const logs: string[] = []; - jest - .spyOn(transports.Console.prototype, 'log') - .mockImplementation((s: any, next: any) => { - logs.push(s.message); - if (next) { - next(); - } - return undefined; - }); - - process.env.LOG_LEVEL = 'info'; - const logger = getRootLogger(); - await loadBackendConfig({ - logger, - argv: [], - additionalConfigs, - additionalSchemas, - }); - logger.info('test'); - logger.info('shouldBeHidden'); - logger.end(); - await new Promise(resolve => { - logger.on('finish', resolve); - }); - - expect(logs[0]).toMatch( - /Found \d+ new secrets in config that will be redacted/, - ); - expect(logs[1]).toEqual('test'); - expect(logs[2]).toEqual('[REDACTED]'); - }, 30000); - }); -}); diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index ac89f3f4ec..b452d8db70 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -22,7 +22,6 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { AppConfig, Config } from '@backstage/config'; import { LoadConfigOptionsRemote } from '@backstage/config-loader'; import { setRootLoggerRedactionList } from './logging/createRootLogger'; -import { JsonObject } from '@backstage/types'; /** * Load configuration for a Backend. @@ -36,13 +35,11 @@ export async function loadBackendConfig(options: { // process.argv or any other overrides remote?: LoadConfigOptionsRemote; additionalConfigs?: AppConfig[]; - additionalSchemas?: { [context: string]: JsonObject }; argv: string[]; watch?: boolean; }): Promise { const secretEnumerator = await createConfigSecretEnumerator({ logger: options.logger, - additionalSchemas: options.additionalSchemas, }); const { config } = await newLoadBackendConfig(options); diff --git a/packages/backend-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index 5a9e93f50b..64bffb468c 100644 --- a/packages/backend-dynamic-feature-service/api-report.md +++ b/packages/backend-dynamic-feature-service/api-report.md @@ -7,6 +7,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstagePackageJson } from '@backstage/cli-node'; import { CatalogBuilder } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; +import { ConfigSchema } from '@backstage/config-loader'; import { EventBroker } from '@backstage/plugin-events-node'; import { EventsBackend } from '@backstage/plugin-events-backend'; import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha'; @@ -23,8 +24,8 @@ import { PluginCacheManager } from '@backstage/backend-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { RootLoggerService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; -import { SchemaDiscoveryService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { TaskRunner } from '@backstage/backend-tasks'; @@ -117,10 +118,32 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory< >; // @public (undocumented) -export interface DynamicPluginsSchemaDiscoveryOptions { +export const dynamicPluginsFrontendSchemas: () => BackendFeature; + +// @public (undocumented) +export const dynamicPluginsRootLoggerServiceFactory: () => ServiceFactory< + RootLoggerService, + 'root' +>; + +// @public (undocumented) +export interface DynamicPluginsSchemasOptions { schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; } +// @public (undocumented) +export interface DynamicPluginsSchemasService { + // (undocumented) + addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{ + schema: ConfigSchema; + }>; +} + +// @public (undocumented) +export const dynamicPluginsSchemasServiceFactory: ( + options?: DynamicPluginsSchemasOptions | undefined, +) => ServiceFactory; + // @public (undocumented) export const dynamicPluginsServiceFactory: ( options?: DynamicPluginsFactoryOptions | undefined, @@ -226,10 +249,5 @@ export interface ScannedPluginPackage { manifest: ScannedPluginManifest; } -// @public (undocumented) -export const schemaDiscoveryServiceFactory: ( - options?: DynamicPluginsSchemaDiscoveryOptions | undefined, -) => ServiceFactory; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index bc99c2a692..8699d66b2a 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -12,6 +12,16 @@ "backstage": { "role": "node-library" }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-dynamic-feature-service" + }, "keywords": [ "backstage" ], @@ -26,6 +36,7 @@ "start": "backstage-cli package start" }, "dependencies": { + "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", @@ -34,6 +45,7 @@ "@backstage/config": "workspace:^", "@backstage/config-loader": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/plugin-app-node": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-events-backend": "workspace:^", @@ -44,20 +56,21 @@ "@backstage/plugin-search-backend-node": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/types": "workspace:^", + "@manypkg/get-packages": "^1.1.3", "@types/express": "^4.17.6", "chokidar": "^3.5.3", "express": "^4.17.1", - "fs-extra": "^7.0.1", + "fs-extra": "10.1.0", "lodash": "^4.17.21", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/backend-app-api": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "wait-for-expect": "^3.0.2" }, "files": [ - "dist" + "dist", + "config.d.ts" ] } diff --git a/packages/backend-dynamic-feature-service/src/index.ts b/packages/backend-dynamic-feature-service/src/index.ts index 5570d8ee29..3768146124 100644 --- a/packages/backend-dynamic-feature-service/src/index.ts +++ b/packages/backend-dynamic-feature-service/src/index.ts @@ -17,3 +17,4 @@ export * from './loader'; export * from './scanner'; export * from './manager'; +export * from './schemas'; diff --git a/packages/backend-dynamic-feature-service/src/scanner/index.ts b/packages/backend-dynamic-feature-service/src/scanner/index.ts index 22b476c143..c5574cc38e 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/index.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/index.ts @@ -15,5 +15,3 @@ */ export type { ScannedPluginManifest, ScannedPluginPackage } from './types'; -export type { DynamicPluginsSchemaDiscoveryOptions } from './plugin-scanner'; -export { schemaDiscoveryServiceFactory } from './plugin-scanner'; diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index a9e11ca60b..af1144f019 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -22,15 +22,7 @@ import * as path from 'path'; import * as url from 'url'; import debounce from 'lodash/debounce'; import { PackagePlatform, PackageRoles } from '@backstage/cli-node'; -import { - LoggerService, - coreServices, - createServiceFactory, -} from '@backstage/backend-plugin-api'; -import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; -import { findPaths } from '@backstage/cli-common'; -import { gatherDynamicPluginsSchemas } from './schemas'; -import { JsonObject } from '@backstage/types'; +import { LoggerService } from '@backstage/backend-plugin-api'; export interface DynamicPluginScannerOptions { config: Config; @@ -323,72 +315,3 @@ export class PluginScanner { this.untrackChanges(); } } - -/** - * @public - */ -export interface DynamicPluginsSchemaDiscoveryOptions { - /** - * Function that returns the path to the Json schema file for a given scanned plugin package. - * The path is either absolute, or relative to the plugin package root directory. - * - * Default behavior is to look for the `dist/configSchema.json` relative path. - * - * @param pluginPackage - The scanned plugin package. - * @returns the absolute or plugin-relative path to the Json schema file. - */ - schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; -} - -/** - * @public - */ -export const schemaDiscoveryServiceFactory = createServiceFactory( - (options?: DynamicPluginsSchemaDiscoveryOptions) => ({ - service: schemaDiscoveryServiceRef, - deps: { - config: coreServices.rootConfig, - }, - factory({ config }) { - let schemas: { [context: string]: JsonObject } | undefined; - - return { - async getAdditionalSchemas(): Promise<{ - schemas: { [context: string]: JsonObject }; - }> { - if (schemas) { - return { - schemas, - }; - } - - const logger = { - ...console, - child() { - return this; - }, - }; - - const scanner = PluginScanner.create({ - config, - logger, - // eslint-disable-next-line no-restricted-syntax - backstageRoot: findPaths(__dirname).targetRoot, - preferAlpha: true, - }); - - const { packages } = await scanner.scanRoot(); - - schemas = await gatherDynamicPluginsSchemas( - packages, - logger, - options?.schemaLocator, - ); - return { - schemas, - }; - }, - }; - }, - }), -); diff --git a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts b/packages/backend-dynamic-feature-service/src/scanner/schemas.ts deleted file mode 100644 index f00828859a..0000000000 --- a/packages/backend-dynamic-feature-service/src/scanner/schemas.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ScannedPluginPackage } from '@backstage/backend-dynamic-feature-service'; -import fs from 'fs-extra'; -import * as path from 'path'; -import * as url from 'url'; -import { isEmpty } from 'lodash'; -import { LoggerService } from '@backstage/backend-plugin-api'; -import { JsonObject } from '@backstage/types'; - -export async function gatherDynamicPluginsSchemas( - packages: ScannedPluginPackage[], - logger: LoggerService, - schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => - path.join('dist', 'configSchema.json'), -): Promise<{ [context: string]: JsonObject }> { - const allSchemas: { [context: string]: JsonObject } = {}; - - for (const pluginPackage of packages) { - let schemaLocation = schemaLocator(pluginPackage); - - if (!path.isAbsolute(schemaLocation)) { - let pluginLocation = url.fileURLToPath(pluginPackage.location); - if (path.basename(pluginLocation) === 'alpha') { - pluginLocation = path.dirname(pluginLocation); - } - schemaLocation = path.resolve(pluginLocation, schemaLocation); - } - - if (!(await fs.pathExists(schemaLocation))) { - continue; - } - - const serialized = await fs.readJson(schemaLocation); - if (!serialized) { - continue; - } - - if (isEmpty(serialized)) { - continue; - } - - if (!serialized?.$schema || serialized?.type !== 'object') { - logger.error( - `Serialized configuration schema is invalid for plugin ${pluginPackage.manifest.name}`, - ); - continue; - } - - allSchemas[schemaLocation] = serialized; - } - - return allSchemas; -} diff --git a/packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts b/packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts new file mode 100644 index 0000000000..b44913edfd --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { dynamicPluginsSchemasServiceRef } from './schemas'; +import { + configSchemaExtensionPoint, + loadCompiledConfigSchema, +} from '@backstage/plugin-app-node'; +import { resolvePackagePath } from '@backstage/backend-common'; + +/** @public */ +export const dynamicPluginsFrontendSchemas = createBackendModule({ + pluginId: 'app', + moduleId: 'core.dynamicplugins.frontendSchemas', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + schemas: dynamicPluginsSchemasServiceRef, + configSchemaExtension: configSchemaExtensionPoint, + }, + async init({ config, schemas, configSchemaExtension }) { + const appPackageName = + config.getOptionalString('app.packageName') ?? 'app'; + const appDistDir = resolvePackagePath(appPackageName, 'dist'); + const compiledConfigSchema = await loadCompiledConfigSchema(appDistDir); + if (compiledConfigSchema) { + configSchemaExtension.setConfigSchema( + (await schemas.addDynamicPluginsSchemas(compiledConfigSchema)) + .schema, + ); + } + }, + }); + }, +}); diff --git a/packages/backend-dynamic-feature-service/src/schemas/index.ts b/packages/backend-dynamic-feature-service/src/schemas/index.ts new file mode 100644 index 0000000000..94c167679b --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schemas/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + dynamicPluginsSchemasServiceFactory, + type DynamicPluginsSchemasService, + type DynamicPluginsSchemasOptions, +} from './schemas'; + +export { dynamicPluginsFrontendSchemas } from './appBackendModule'; +export { dynamicPluginsRootLoggerServiceFactory } from './rootLoggerServiceFactory'; diff --git a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts new file mode 100644 index 0000000000..6681a65a75 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createServiceFactory, + coreServices, +} from '@backstage/backend-plugin-api'; +import { WinstonLogger } from '@backstage/backend-app-api'; +import { transports, format } from 'winston'; +import { createConfigSecretEnumerator } from '@backstage/backend-app-api'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { getPackages } from '@manypkg/get-packages'; +import { dynamicPluginsSchemasServiceRef } from './schemas'; + +/** @public */ +export const dynamicPluginsRootLoggerServiceFactory = createServiceFactory({ + service: coreServices.rootLogger, + deps: { + config: coreServices.rootConfig, + schemas: dynamicPluginsSchemasServiceRef, + }, + async factory({ config, schemas }) { + const logger = WinstonLogger.create({ + meta: { + service: 'backstage', + }, + level: process.env.LOG_LEVEL || 'info', + format: + process.env.NODE_ENV === 'production' + ? format.json() + : WinstonLogger.colorFormat(), + transports: [new transports.Console()], + }); + + const configSchema = await loadConfigSchema({ + dependencies: ( + await getPackages(process.cwd()) + ).packages.map(p => p.packageJson.name), + }); + + const secretEnumerator = await createConfigSecretEnumerator({ + logger, + schema: (await schemas.addDynamicPluginsSchemas(configSchema)).schema, + }); + logger.addRedactions(secretEnumerator(config)); + config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); + + return logger; + }, +}); diff --git a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts new file mode 100644 index 0000000000..0c03e93ada --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScannedPluginPackage } from '../scanner/types'; +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { findPaths } from '@backstage/cli-common'; + +import fs from 'fs-extra'; +import * as path from 'path'; +import * as url from 'url'; +import { isEmpty } from 'lodash'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { PluginScanner } from '../scanner/plugin-scanner'; +import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader'; + +/** + * + * @public + * */ +export interface DynamicPluginsSchemasService { + addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{ + schema: ConfigSchema; + }>; +} + +/** + * A service that provides the config schemas of scanned dynamic plugins. + * + * @public + */ +export const dynamicPluginsSchemasServiceRef = + createServiceRef({ + id: 'core.dynamicplugins.schemas', + scope: 'root', + }); + +/** + * @public + */ +export interface DynamicPluginsSchemasOptions { + /** + * Function that returns the path to the Json schema file for a given scanned plugin package. + * The path is either absolute, or relative to the plugin package root directory. + * + * Default behavior is to look for the `dist/configSchema.json` relative path. + * + * @param pluginPackage - The scanned plugin package. + * @returns the absolute or plugin-relative path to the Json schema file. + */ + schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; +} + +/** + * @public + */ +export const dynamicPluginsSchemasServiceFactory = createServiceFactory( + (options?: DynamicPluginsSchemasOptions) => ({ + service: dynamicPluginsSchemasServiceRef, + deps: { + config: coreServices.rootConfig, + }, + factory({ config }) { + let additionalSchemas: { [context: string]: JsonObject } | undefined; + + return { + async addDynamicPluginsSchemas(configSchema: ConfigSchema): Promise<{ + schema: ConfigSchema; + }> { + if (!additionalSchemas) { + const logger = { + ...console, + child() { + return this; + }, + }; + + const scanner = PluginScanner.create({ + config, + logger, + // eslint-disable-next-line no-restricted-syntax + backstageRoot: findPaths(__dirname).targetRoot, + preferAlpha: true, + }); + + const { packages } = await scanner.scanRoot(); + + additionalSchemas = await gatherDynamicPluginsSchemas( + packages, + logger, + options?.schemaLocator, + ); + } + + const serialized = configSchema.serialize(); + if (serialized?.backstageConfigSchemaVersion !== 1) { + throw new Error( + 'Serialized configuration schema is invalid or has an invalid version number', + ); + } + const schemas = serialized.schemas as { + value: JsonObject; + path: string; + }[]; + + schemas.push( + ...Object.keys(additionalSchemas).map(context => { + return { + path: context, + value: additionalSchemas![context], + }; + }), + ); + serialized.schemas = schemas; + return { + schema: await loadConfigSchema({ + serialized, + }), + }; + }, + }; + }, + }), +); + +/** @internal */ +async function gatherDynamicPluginsSchemas( + packages: ScannedPluginPackage[], + logger: LoggerService, + schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => + path.join('dist', 'configSchema.json'), +): Promise<{ [context: string]: JsonObject }> { + const allSchemas: { [context: string]: JsonObject } = {}; + + for (const pluginPackage of packages) { + let schemaLocation = schemaLocator(pluginPackage); + + if (!path.isAbsolute(schemaLocation)) { + let pluginLocation = url.fileURLToPath(pluginPackage.location); + if (path.basename(pluginLocation) === 'alpha') { + pluginLocation = path.dirname(pluginLocation); + } + schemaLocation = path.resolve(pluginLocation, schemaLocation); + } + + if (!(await fs.pathExists(schemaLocation))) { + continue; + } + + const serialized = await fs.readJson(schemaLocation); + if (!serialized) { + continue; + } + + if (isEmpty(serialized)) { + continue; + } + + if (!serialized?.$schema || serialized?.type !== 'object') { + logger.error( + `Serialized configuration schema is invalid for plugin ${pluginPackage.manifest.name}`, + ); + continue; + } + + allSchemas[schemaLocation] = serialized; + } + + return allSchemas; +} diff --git a/packages/backend-plugin-api/api-report-alpha.md b/packages/backend-plugin-api/api-report-alpha.md index 0d9eaf4a0e..81b378a671 100644 --- a/packages/backend-plugin-api/api-report-alpha.md +++ b/packages/backend-plugin-api/api-report-alpha.md @@ -4,7 +4,6 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; -import { JsonObject } from '@backstage/types'; import { ServiceRef } from '@backstage/backend-plugin-api'; // @alpha (undocumented) @@ -21,21 +20,5 @@ export const featureDiscoveryServiceRef: ServiceRef< 'root' >; -// @alpha (undocumented) -export interface SchemaDiscoveryService { - // (undocumented) - getAdditionalSchemas(): Promise<{ - schemas: { - [context: string]: JsonObject; - }; - }>; -} - -// @alpha -export const schemaDiscoveryServiceRef: ServiceRef< - SchemaDiscoveryService, - 'root' ->; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 5476a75cf3..a2946bd803 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -47,7 +47,6 @@ "dependencies": { "@backstage/backend-tasks": "workspace:^", "@backstage/config": "workspace:^", - "@backstage/config-loader": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", diff --git a/packages/backend-plugin-api/src/alpha.ts b/packages/backend-plugin-api/src/alpha.ts index 7d7e3177c2..baee739f49 100644 --- a/packages/backend-plugin-api/src/alpha.ts +++ b/packages/backend-plugin-api/src/alpha.ts @@ -16,11 +16,8 @@ import { BackendFeature, - coreServices, - createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { JsonObject } from '@backstage/types'; /** @alpha */ export interface FeatureDiscoveryService { @@ -36,34 +33,3 @@ export const featureDiscoveryServiceRef = id: 'core.featureDiscovery', scope: 'root', }); - -/** @alpha */ -export interface SchemaDiscoveryService { - getAdditionalSchemas(): Promise<{ - schemas: { [context: string]: JsonObject }; - }>; -} - -/** - * An optional service that can be used to dynamically load in additional BackendFeatures at runtime. - * @alpha - */ -export const schemaDiscoveryServiceRef = - createServiceRef({ - id: 'core.schemaDiscovery', - scope: 'root', - defaultFactory: async service => - createServiceFactory({ - service, - deps: { - config: coreServices.rootConfig, - }, - factory() { - return { - async getAdditionalSchemas() { - return { schemas: {} }; - }, - }; - }, - }), - }); diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index c3fc9f522e..66572dd960 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -193,9 +193,6 @@ export type LoadConfigSchemaOptions = ( } ) & { noUndeclaredProperties?: boolean; - additionalSchemas?: { - [context: string]: JsonObject; - }; }; // @public diff --git a/packages/config-loader/src/schema/index.ts b/packages/config-loader/src/schema/index.ts index 01c18ee905..1dcb9d7b4b 100644 --- a/packages/config-loader/src/schema/index.ts +++ b/packages/config-loader/src/schema/index.ts @@ -22,5 +22,4 @@ export type { ConfigVisibility, ConfigSchemaProcessingOptions, TransformFunc, - ConfigSchemaPackageEntry, } from './types'; diff --git a/packages/config-loader/src/schema/load.test.ts b/packages/config-loader/src/schema/load.test.ts index f9299340c3..525565f3d5 100644 --- a/packages/config-loader/src/schema/load.test.ts +++ b/packages/config-loader/src/schema/load.test.ts @@ -126,57 +126,6 @@ describe('loadConfigSchema', () => { ); }); - it('should include additional schemas', async () => { - const schema = await loadConfigSchema({ - serialized: { - backstageConfigSchemaVersion: 1, - schemas: [ - { - path: 'mainSchema', - value: { - type: 'object', - properties: { - key1: { type: 'string', visibility: 'frontend' }, - }, - }, - }, - ], - }, - additionalSchemas: { - additionalSchema: { - type: 'object', - properties: { - additionalKey: { type: 'string', visibility: 'frontend' }, - }, - }, - }, - }); - - expect(schema.serialize()).toEqual({ - backstageConfigSchemaVersion: 1, - schemas: [ - { - path: 'mainSchema', - value: { - type: 'object', - properties: { - key1: { type: 'string', visibility: 'frontend' }, - }, - }, - }, - { - path: 'additionalSchema', - value: { - type: 'object', - properties: { - additionalKey: { type: 'string', visibility: 'frontend' }, - }, - }, - }, - ], - }); - }); - describe('should consider schema', () => { it('when filtering simple config', async () => { mockDir.setContent({ diff --git a/packages/config-loader/src/schema/load.ts b/packages/config-loader/src/schema/load.ts index 03b85b2756..885da44116 100644 --- a/packages/config-loader/src/schema/load.ts +++ b/packages/config-loader/src/schema/load.ts @@ -43,7 +43,6 @@ export type LoadConfigSchemaOptions = } ) & { noUndeclaredProperties?: boolean; - additionalSchemas?: { [context: string]: JsonObject }; }; function errorsToError(errors: ValidationError[]): Error { @@ -84,16 +83,6 @@ export async function loadConfigSchema( } schemas = serialized.schemas as ConfigSchemaPackageEntry[]; } - if (options.additionalSchemas) { - schemas.push( - ...Object.keys(options.additionalSchemas).map(context => { - return { - path: context, - value: options.additionalSchemas![context], - }; - }), - ); - } const validate = compileConfigSchemas(schemas, { noUndeclaredProperties: options.noUndeclaredProperties, diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index fd85b68a87..0afc7021ad 100644 --- a/plugins/app-backend/api-report.md +++ b/plugins/app-backend/api-report.md @@ -4,8 +4,8 @@ ```ts import { Config } from '@backstage/config'; +import { ConfigSchema } from '@backstage/config-loader'; import express from 'express'; -import { JsonObject } from '@backstage/types'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -14,9 +14,6 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { - additionalSchemas?: { - [context: string]: JsonObject; - }; appPackageName: string; // (undocumented) config: Config; @@ -24,6 +21,7 @@ export interface RouterOptions { disableConfigInjection?: boolean; // (undocumented) logger: Logger; + schema?: ConfigSchema; staticFallbackHandler?: express.Handler; } ``` diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index dc041a2593..f09ba5c5ec 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -19,7 +19,11 @@ import { resolve as resolvePath } from 'path'; import { Logger } from 'winston'; import { AppConfig, Config } from '@backstage/config'; import { JsonObject } from '@backstage/types'; -import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader'; +import { + ConfigSchema, + loadConfigSchema, + readEnvConfig, +} from '@backstage/config-loader'; type InjectOptions = { appConfigs: AppConfig[]; @@ -74,7 +78,7 @@ type ReadOptions = { env: { [name: string]: string | undefined }; appDistDir: string; config: Config; - additionalSchemas?: { [context: string]: JsonObject }; + schema?: ConfigSchema; }; /** @@ -91,10 +95,11 @@ export async function readConfigs(options: ReadOptions): Promise { const serializedSchema = await fs.readJson(schemaPath); try { - const schema = await loadConfigSchema({ - serialized: serializedSchema, - additionalSchemas: options.additionalSchemas, - }); + const schema = + options.schema || + (await loadConfigSchema({ + serialized: serializedSchema, + })); const frontendConfigs = await schema.process( [{ data: config.get() as JsonObject, context: 'app' }], diff --git a/plugins/app-backend/src/service/appPlugin.ts b/plugins/app-backend/src/service/appPlugin.ts index 07616e832f..a2ea05623f 100644 --- a/plugins/app-backend/src/service/appPlugin.ts +++ b/plugins/app-backend/src/service/appPlugin.ts @@ -19,10 +19,13 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; -import { schemaDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { createRouter } from './router'; import { loggerToWinstonLogger } from '@backstage/backend-common'; -import { staticFallbackHandlerExtensionPoint } from '@backstage/plugin-app-node'; +import { + configSchemaExtensionPoint, + staticFallbackHandlerExtensionPoint, +} from '@backstage/plugin-app-node'; +import { ConfigSchema } from '@backstage/config-loader'; /** * The App plugin is responsible for serving the frontend app bundle and static assets. @@ -32,6 +35,7 @@ export const appPlugin = createBackendPlugin({ pluginId: 'app', register(env) { let staticFallbackHandler: express.Handler | undefined; + let schema: ConfigSchema | undefined; env.registerExtensionPoint(staticFallbackHandlerExtensionPoint, { setStaticFallbackHandler(handler) { @@ -44,17 +48,28 @@ export const appPlugin = createBackendPlugin({ }, }); + env.registerExtensionPoint(configSchemaExtensionPoint, { + setConfigSchema(configSchema) { + if (schema) { + throw new Error( + 'Attempted to set config schema for the app-backend twice', + ); + } + schema = configSchema; + }, + }); + env.registerInit({ deps: { logger: coreServices.logger, config: coreServices.rootConfig, database: coreServices.database, httpRouter: coreServices.httpRouter, - schemaDiscovery: schemaDiscoveryServiceRef, }, - async init({ logger, config, database, httpRouter, schemaDiscovery }) { + async init({ logger, config, database, httpRouter }) { const appPackageName = config.getOptionalString('app.packageName') ?? 'app'; + const winstonLogger = loggerToWinstonLogger(logger); const router = await createRouter({ @@ -63,9 +78,7 @@ export const appPlugin = createBackendPlugin({ database, appPackageName, staticFallbackHandler, - additionalSchemas: ( - await schemaDiscovery?.getAdditionalSchemas() - )?.schemas, + schema, }); httpRouter.use(router); }, diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 204d5df26a..17c5fd4f84 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -37,7 +37,7 @@ import { CACHE_CONTROL_NO_CACHE, CACHE_CONTROL_REVALIDATE_CACHE, } from '../lib/headers'; -import { JsonObject } from '@backstage/types'; +import { ConfigSchema } from '@backstage/config-loader'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -89,14 +89,10 @@ export interface RouterOptions { /** * - * Provides a map of additional config schemas, in addition to the serialized schemas - * generated during the application build. - * This is useful when additional plugins are dynamically loaded in the application at start, - * which were not part of the application build. This option allows feeding the corresponding - * JSON schemas. + * Provides a ConfigSchema. * */ - additionalSchemas?: { [context: string]: JsonObject }; + schema?: ConfigSchema; } /** @public */ @@ -133,7 +129,7 @@ export async function createRouter( config, appDistDir, env: process.env, - additionalSchemas: options.additionalSchemas, + schema: options.schema, }); injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir }); diff --git a/plugins/app-node/api-report.md b/plugins/app-node/api-report.md index 504c9d48c1..58e51e2b5c 100644 --- a/plugins/app-node/api-report.md +++ b/plugins/app-node/api-report.md @@ -3,9 +3,23 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ConfigSchema } from '@backstage/config-loader'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { Handler } from 'express'; +// @public +export interface ConfigSchemaExtensionPoint { + setConfigSchema(configSchema: ConfigSchema): void; +} + +// @public +export const configSchemaExtensionPoint: ExtensionPoint; + +// @public +export function loadCompiledConfigSchema( + appDistDir: string, +): Promise; + // @public export interface StaticFallbackHandlerExtensionPoint { setStaticFallbackHandler(handler: Handler): void; diff --git a/plugins/app-node/package.json b/plugins/app-node/package.json index 8da14adf64..d55fd05e98 100644 --- a/plugins/app-node/package.json +++ b/plugins/app-node/package.json @@ -29,7 +29,9 @@ ], "dependencies": { "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config-loader": "workspace:^", "@types/express": "^4.17.6", - "express": "^4.17.1" + "express": "^4.17.1", + "fs-extra": "10.1.0" } } diff --git a/plugins/app-node/src/extensions.ts b/plugins/app-node/src/extensions.ts index 6a75a5b791..6cb612f8df 100644 --- a/plugins/app-node/src/extensions.ts +++ b/plugins/app-node/src/extensions.ts @@ -15,6 +15,7 @@ */ import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { ConfigSchema } from '@backstage/config-loader'; import { Handler } from 'express'; /** @@ -50,3 +51,25 @@ export const staticFallbackHandlerExtensionPoint = createExtensionPoint({ id: 'app.staticFallbackHandler', }); + +/** + * The interface for {@link configSchemaExtensionPoint}. + * + * @public + */ +export interface ConfigSchemaExtensionPoint { + /** + * Sets the config schema. This can only be done once. + */ + setConfigSchema(configSchema: ConfigSchema): void; +} + +/** + * An extension point the exposes the ability to override the config schema used by the frontend application. + * + * @public + */ +export const configSchemaExtensionPoint = + createExtensionPoint({ + id: 'app.configSchema', + }); diff --git a/plugins/app-node/src/index.ts b/plugins/app-node/src/index.ts index c5189fa8b9..4695471cf8 100644 --- a/plugins/app-node/src/index.ts +++ b/plugins/app-node/src/index.ts @@ -23,4 +23,8 @@ export { staticFallbackHandlerExtensionPoint, type StaticFallbackHandlerExtensionPoint, + configSchemaExtensionPoint, + type ConfigSchemaExtensionPoint, } from './extensions'; + +export { loadCompiledConfigSchema } from './schema'; diff --git a/plugins/app-node/src/schema.ts b/plugins/app-node/src/schema.ts new file mode 100644 index 0000000000..a38fd585d9 --- /dev/null +++ b/plugins/app-node/src/schema.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader'; + +/** + * Loads the config schema that is embedded in the frontend build. + * + * @public + */ +export async function loadCompiledConfigSchema( + appDistDir: string, +): Promise { + const schemaPath = resolvePath(appDistDir, '.config-schema.json'); + if (await fs.pathExists(schemaPath)) { + const serializedSchema = await fs.readJson(schemaPath); + + return await loadConfigSchema({ + serialized: serializedSchema, + }); + } + + return undefined; +} diff --git a/yarn.lock b/yarn.lock index 0e32adda8c..3e3843b1a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3382,6 +3382,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-app-node": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-events-backend": "workspace:^" @@ -3392,10 +3393,11 @@ __metadata: "@backstage/plugin-search-backend-node": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/types": "workspace:^" + "@manypkg/get-packages": ^1.1.3 "@types/express": ^4.17.6 chokidar: ^3.5.3 express: ^4.17.1 - fs-extra: ^7.0.1 + fs-extra: 10.1.0 lodash: ^4.17.21 wait-for-expect: ^3.0.2 winston: ^3.2.1 @@ -3429,7 +3431,6 @@ __metadata: "@backstage/backend-tasks": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" - "@backstage/config-loader": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/types": "workspace:^" @@ -4647,8 +4648,10 @@ __metadata: dependencies: "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config-loader": "workspace:^" "@types/express": ^4.17.6 express: ^4.17.1 + fs-extra: 10.1.0 languageName: unknown linkType: soft From b0f33dc77a33c0e6187c9dae594cdacf444df6cb Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 12 Feb 2024 20:11:23 +0100 Subject: [PATCH 6/8] Allow `backend-dynamic-feature-service` to depend on `backend-app-api` Signed-off-by: David Festal --- scripts/verify-local-dependencies.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index 5d75348681..e2aaf8c33a 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -90,6 +90,7 @@ const roleRules = [ '@backstage/backend-common', '@backstage/backend-defaults', '@backstage/backend-test-utils', + '@backstage/backend-dynamic-feature-service', ], message: `Plugin package SOURCE_NAME with role SOURCE_ROLE has a runtime dependency on package TARGET_NAME, which is not permitted. If you are using this dependency for dev server purposes, you can move it to devDependencies instead.`, }, From 5d338bea6ec90d5a3d9dda784a4921e5581081b1 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 12 Feb 2024 20:20:38 +0100 Subject: [PATCH 7/8] fix a `package.json` Signed-off-by: David Festal --- packages/backend-dynamic-feature-service/package.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 8699d66b2a..7066c6c2ea 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -5,9 +5,7 @@ "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "access": "public" }, "backstage": { "role": "node-library" @@ -16,6 +14,13 @@ ".": "./src/index.ts", "./package.json": "./package.json" }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, "homepage": "https://backstage.io", "repository": { "type": "git", From 8b62f68b48e4cebf5dcdda4da9a3fa05bdfc7fd4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 13 Feb 2024 11:46:25 +0100 Subject: [PATCH 8/8] Apply suggestions from code review Signed-off-by: Patrik Oldsberg --- .changeset/late-poets-love.md | 2 +- .changeset/rich-bees-fry.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/late-poets-love.md b/.changeset/late-poets-love.md index bd6692560d..03b975b2b5 100644 --- a/.changeset/late-poets-love.md +++ b/.changeset/late-poets-love.md @@ -2,4 +2,4 @@ '@backstage/backend-app-api': patch --- -Allow the `createConfigSecretEnumerator` to take an optional `configSchema` argument with an already-loaded global configuration schema. +Allow the `createConfigSecretEnumerator` to take an optional `schema` argument with an already-loaded global configuration schema. diff --git a/.changeset/rich-bees-fry.md b/.changeset/rich-bees-fry.md index 3dcfa95532..281719340b 100644 --- a/.changeset/rich-bees-fry.md +++ b/.changeset/rich-bees-fry.md @@ -1,6 +1,6 @@ --- -'@backstage/plugin-app-backend': minor -'@backstage/plugin-app-node': minor +'@backstage/plugin-app-backend': patch +'@backstage/plugin-app-node': patch --- Allow the `app-backend` plugin to use a global configuration schema provided externally through an extension.