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