diff --git a/.changeset/late-poets-love.md b/.changeset/late-poets-love.md new file mode 100644 index 0000000000..03b975b2b5 --- /dev/null +++ b/.changeset/late-poets-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +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 new file mode 100644 index 0000000000..281719340b --- /dev/null +++ b/.changeset/rich-bees-fry.md @@ -0,0 +1,6 @@ +--- +'@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. diff --git a/.changeset/swift-pumpkins-shake.md b/.changeset/swift-pumpkins-shake.md new file mode 100644 index 0000000000..c7a0a44ade --- /dev/null +++ b/.changeset/swift-pumpkins-shake.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-dynamic-feature-service': minor +--- + +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 e478a91bb7..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,6 +65,7 @@ export const cacheServiceFactory: () => ServiceFactory; export function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; + 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 8ecc015b66..8f98c5597c 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, + ConfigSchema, } from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import type { Config, AppConfig } from '@backstage/config'; @@ -34,12 +35,15 @@ import { isValidUrl } from '../lib/urls'; export async function createConfigSecretEnumerator(options: { logger: LoggerService; dir?: string; + 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), - }); + 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-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index caf2cf01ec..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,6 +24,7 @@ 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 { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; @@ -115,6 +117,33 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory< 'root' >; +// @public (undocumented) +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, diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 01616e38b8..a647dcbf45 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" }, "repository": { "type": "git", @@ -17,6 +15,23 @@ "backstage": { "role": "node-library" }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "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" ], @@ -31,13 +46,16 @@ "start": "backstage-cli package start" }, "dependencies": { + "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", "@backstage/cli-common": "workspace:^", "@backstage/cli-node": "workspace:^", "@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:^", @@ -48,20 +66,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": "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:^", - "@backstage/config-loader": "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/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/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md index 0383129823..0afc7021ad 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 { ConfigSchema } from '@backstage/config-loader'; import express from 'express'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -20,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 63912df5c4..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,6 +78,7 @@ type ReadOptions = { env: { [name: string]: string | undefined }; appDistDir: string; config: Config; + schema?: ConfigSchema; }; /** @@ -90,7 +95,11 @@ export async function readConfigs(options: ReadOptions): Promise { const serializedSchema = await fs.readJson(schemaPath); try { - const schema = await loadConfigSchema({ serialized: serializedSchema }); + 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 9ba2ea6d6c..a2ea05623f 100644 --- a/plugins/app-backend/src/service/appPlugin.ts +++ b/plugins/app-backend/src/service/appPlugin.ts @@ -21,7 +21,11 @@ import { } from '@backstage/backend-plugin-api'; 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. @@ -31,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) { @@ -43,6 +48,17 @@ 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, @@ -53,6 +69,7 @@ export const appPlugin = createBackendPlugin({ async init({ logger, config, database, httpRouter }) { const appPackageName = config.getOptionalString('app.packageName') ?? 'app'; + const winstonLogger = loggerToWinstonLogger(logger); const router = await createRouter({ @@ -61,6 +78,7 @@ export const appPlugin = createBackendPlugin({ database, appPackageName, staticFallbackHandler, + schema, }); httpRouter.use(router); }, diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 5574892d37..17c5fd4f84 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 { ConfigSchema } 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,13 @@ export interface RouterOptions { * This also disables configuration injection though `APP_CONFIG_` environment variables. */ disableConfigInjection?: boolean; + + /** + * + * Provides a ConfigSchema. + * + */ + schema?: ConfigSchema; } /** @public */ @@ -121,6 +129,7 @@ export async function createRouter( config, appDistDir, env: process.env, + 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 7ea9fa1d3c..01acf88e7b 100644 --- a/plugins/app-node/package.json +++ b/plugins/app-node/package.json @@ -34,7 +34,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/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.`, }, diff --git a/yarn.lock b/yarn.lock index d4c7e23be6..04c963a30d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3383,6 +3383,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:^" @@ -3393,9 +3394,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: 10.1.0 lodash: ^4.17.21 wait-for-expect: ^3.0.2 winston: ^3.2.1 @@ -4647,8 +4650,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