diff --git a/.changeset/old-cats-shake.md b/.changeset/old-cats-shake.md new file mode 100644 index 0000000000..3ca30a0421 --- /dev/null +++ b/.changeset/old-cats-shake.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/backend-plugin-api': minor +--- + +Adds a new experimental `RootSystemMetadataService` for tracking the collection of Backstage instances that may be deployed at any one time. It currently offers a single API, `getInstalledPlugins` that returns a list of installed plugins based on config you have set up in `discovery.endpoints` as well as the plugins installed on the instance you're calling the API with. It does not handle wildcard values or fallback values. The intention is for this plugin to provide plugin authors with a simple interface to fetch a trustworthy list of all installed plugins. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 80e29c8715..63ea7b4b55 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -214,6 +214,7 @@ "@types/yauzl": "^2.10.0", "aws-sdk-client-mock": "^4.0.0", "better-sqlite3": "^12.0.0", + "get-port": "^5.1.1", "http-errors": "^2.0.0", "msw": "^1.0.0", "node-mocks-http": "^1.0.0", diff --git a/packages/backend-defaults/report-alpha.api.md b/packages/backend-defaults/report-alpha.api.md index 58277b5e1e..c40517863a 100644 --- a/packages/backend-defaults/report-alpha.api.md +++ b/packages/backend-defaults/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; +import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public (undocumented) @@ -21,5 +22,12 @@ export const actionsServiceFactory: ServiceFactory< 'singleton' >; +// @alpha +export const rootSystemMetadataServiceFactory: ServiceFactory< + RootSystemMetadataService, + 'root', + 'singleton' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts new file mode 100644 index 0000000000..13da5ae3c0 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { + coreServices, + createBackendPlugin, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import Router from 'express-promise-router'; +import request from 'supertest'; +import { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory'; +import { rootSystemMetadataServiceRef } from '@backstage/backend-plugin-api/alpha'; + +describe('SystemMetadataService', () => { + describe('returns plugins from config', () => { + const testPlugin = createBackendPlugin({ + pluginId: 'test-plugin', + register(reg) { + reg.registerInit({ + deps: { + systemMetadata: rootSystemMetadataServiceRef, + rootHttpRouter: coreServices.rootHttpRouter, + }, + init: async ({ systemMetadata, rootHttpRouter }) => { + const router = Router(); + router.use('/plugins', async (_, res) => { + res.json(await systemMetadata.getInstalledPlugins()); + }); + rootHttpRouter.use('/systemMetadata', router); + }, + }); + }, + }); + + it('should list all known instances', async () => { + const { server } = await startTestBackend({ + features: [ + testPlugin, + rootSystemMetadataServiceFactory, + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { + port: 0, + }, + baseUrl: `http://localhost:0`, + }, + }, + }), + ], + }); + + const instanceResponse = await request(server).get( + `/systemMetadata/plugins`, + ); + + expect(instanceResponse.status).toBe(200); + expect(instanceResponse.body).toMatchObject([ + { + pluginId: 'test-plugin', + }, + ]); + }); + + it('should react to config updates', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + listen: { + port: 0, + }, + baseUrl: `http://localhost:0`, + }, + }, + }); + const configFactory = createServiceFactory({ + service: coreServices.rootConfig, + deps: {}, + factory: () => config, + }); + const { server } = await startTestBackend({ + features: [testPlugin, configFactory, rootSystemMetadataServiceFactory], + }); + + const initialResponse = await request(server).get( + `/systemMetadata/plugins`, + ); + + expect(initialResponse.status).toBe(200); + expect(initialResponse.body).toMatchObject([ + { + pluginId: 'test-plugin', + }, + ]); + + config.update({ + data: { + backend: { + listen: { + port: 0, + }, + baseUrl: `http://localhost:0`, + }, + discovery: { + endpoints: [ + { + target: `http://test.internal`, + plugins: ['your-new-plugin'], + }, + ], + }, + }, + }); + + const responseAfterUpdate = await request(server).get( + `/systemMetadata/plugins`, + ); + + expect(responseAfterUpdate.status).toBe(200); + expect(responseAfterUpdate.body).toMatchObject([ + { + pluginId: 'your-new-plugin', + }, + { + pluginId: 'test-plugin', + }, + ]); + }); + }); +}); diff --git a/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/index.ts b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/index.ts new file mode 100644 index 0000000000..cff4eccdbe --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory'; +export { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService'; diff --git a/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts new file mode 100644 index 0000000000..edbffdfb0e --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts @@ -0,0 +1,74 @@ +/* + * 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 { + LoggerService, + RootConfigService, + RootInstanceMetadataService, +} from '@backstage/backend-plugin-api'; +import { + RootSystemMetadataService, + RootSystemMetadataServicePluginInfo, +} from '@backstage/backend-plugin-api/alpha'; +import { getEndpoints } from '../../../../entrypoints/discovery/parsing'; +import { Config } from '@backstage/config'; + +function getPlugins(config: Config): string[] { + const endpoints = getEndpoints(config); + return Array.from(new Set(endpoints.flatMap(endpoint => endpoint.plugins))); +} + +/** + * @alpha + */ +export class DefaultRootSystemMetadataService + implements RootSystemMetadataService +{ + #plugins: string[]; + #instanceMetadata: RootInstanceMetadataService; + constructor(options: { + logger: LoggerService; + config: RootConfigService; + instanceMetadata: RootInstanceMetadataService; + }) { + const { config } = options; + this.#plugins = getPlugins(config); + config.subscribe?.(() => { + this.#plugins = getPlugins(config); + }); + this.#instanceMetadata = options.instanceMetadata; + } + + public static create(pluginEnv: { + logger: LoggerService; + config: RootConfigService; + instanceMetadata: RootInstanceMetadataService; + }) { + return new DefaultRootSystemMetadataService(pluginEnv); + } + + public async getInstalledPlugins(): Promise< + RootSystemMetadataServicePluginInfo[] + > { + const plugins = this.#plugins.map(pluginId => ({ pluginId })); + + for (const plugin of await this.#instanceMetadata.getInstalledPlugins()) { + plugins.push({ pluginId: plugin.pluginId }); + } + + return plugins; + } +} diff --git a/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts new file mode 100644 index 0000000000..5ee93d03d5 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts @@ -0,0 +1,43 @@ +/* + * 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, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService'; +import { rootSystemMetadataServiceRef } from '@backstage/backend-plugin-api/alpha'; + +/** + * Metadata about an entire Backstage system, a collection of Backstage instances. + * + * @alpha + */ +export const rootSystemMetadataServiceFactory = createServiceFactory({ + service: rootSystemMetadataServiceRef, + deps: { + logger: coreServices.rootLogger, + config: coreServices.rootConfig, + instanceMetadata: coreServices.rootInstanceMetadata, + }, + async factory({ logger, config, instanceMetadata }) { + return DefaultRootSystemMetadataService.create({ + logger, + config, + instanceMetadata, + }); + }, +}); diff --git a/packages/backend-defaults/src/alpha/index.ts b/packages/backend-defaults/src/alpha/index.ts index da63aef9de..c17e0e71cb 100644 --- a/packages/backend-defaults/src/alpha/index.ts +++ b/packages/backend-defaults/src/alpha/index.ts @@ -16,3 +16,4 @@ export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; export { actionsServiceFactory } from './entrypoints/actions'; +export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata'; diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts index 5a4631283e..18ed72a085 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts @@ -23,6 +23,7 @@ import { import { readHttpServerOptions } from '../rootHttpRouter/http/config'; import { SrvResolvers } from './SrvResolvers'; import { trimEnd } from 'lodash'; +import { getEndpoints } from './parsing'; type Resolver = (pluginId: string) => Promise; @@ -238,25 +239,7 @@ export class HostDiscovery implements DiscoveryService { const endpoints = defaultEndpoints?.slice() ?? []; // Allow config to override the default endpoints - const endpointConfigs = config.getOptionalConfigArray( - 'discovery.endpoints', - ); - for (const endpointConfig of endpointConfigs ?? []) { - if (typeof endpointConfig.get('target') === 'string') { - endpoints.push({ - target: endpointConfig.getString('target'), - plugins: endpointConfig.getStringArray('plugins'), - }); - } else { - endpoints.push({ - target: { - internal: endpointConfig.getOptionalString('target.internal'), - external: endpointConfig.getOptionalString('target.external'), - }, - plugins: endpointConfig.getStringArray('plugins'), - }); - } - } + endpoints.push(...getEndpoints(config)); // Build up a new set of resolvers const internalResolvers: Map = new Map(); diff --git a/packages/backend-defaults/src/entrypoints/discovery/parsing.ts b/packages/backend-defaults/src/entrypoints/discovery/parsing.ts new file mode 100644 index 0000000000..3a02fc4f51 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/parsing.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 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 { Config } from '@backstage/config'; +import type { HostDiscoveryEndpoint } from './HostDiscovery'; + +export function getEndpoints(config: Config): HostDiscoveryEndpoint[] { + const endpoints: HostDiscoveryEndpoint[] = []; + // Allow config to override the default endpoints + const endpointConfigs = config.getOptionalConfigArray('discovery.endpoints'); + for (const endpointConfig of endpointConfigs ?? []) { + if (typeof endpointConfig.get('target') === 'string') { + endpoints.push({ + target: endpointConfig.getString('target'), + plugins: endpointConfig.getStringArray('plugins'), + }); + } else { + endpoints.push({ + target: { + internal: endpointConfig.getOptionalString('target.internal'), + external: endpointConfig.getOptionalString('target.external'), + }, + plugins: endpointConfig.getStringArray('plugins'), + }); + } + } + return endpoints; +} diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index cefed593c0..e06e0c45d2 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -103,5 +103,26 @@ export const actionsServiceRef: ServiceRef< 'singleton' >; +// @public (undocumented) +export interface RootSystemMetadataService { + // (undocumented) + getInstalledPlugins: () => Promise< + ReadonlyArray + >; +} + +// @public (undocumented) +export interface RootSystemMetadataServicePluginInfo { + // (undocumented) + readonly pluginId: string; +} + +// @alpha +export const rootSystemMetadataServiceRef: ServiceRef< + RootSystemMetadataService, + 'root', + 'singleton' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-plugin-api/src/alpha/RootSystemMetadataService.ts b/packages/backend-plugin-api/src/alpha/RootSystemMetadataService.ts new file mode 100644 index 0000000000..67cc43f8af --- /dev/null +++ b/packages/backend-plugin-api/src/alpha/RootSystemMetadataService.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** @public */ +export interface RootSystemMetadataServicePluginInfo { + readonly pluginId: string; +} + +/** @public */ +export interface RootSystemMetadataService { + getInstalledPlugins: () => Promise< + ReadonlyArray + >; +} diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index b1edd68adc..56f02f5275 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +export type { + RootSystemMetadataServicePluginInfo, + RootSystemMetadataService, +} from './RootSystemMetadataService'; + export type { ActionsRegistryService, ActionsRegistryActionOptions, @@ -22,4 +27,8 @@ export type { export type { ActionsService, ActionsServiceAction } from './ActionsService'; -export { actionsRegistryServiceRef, actionsServiceRef } from './refs'; +export { + actionsRegistryServiceRef, + actionsServiceRef, + rootSystemMetadataServiceRef, +} from './refs'; diff --git a/packages/backend-plugin-api/src/alpha/refs.ts b/packages/backend-plugin-api/src/alpha/refs.ts index cfbb215615..a890271364 100644 --- a/packages/backend-plugin-api/src/alpha/refs.ts +++ b/packages/backend-plugin-api/src/alpha/refs.ts @@ -45,3 +45,14 @@ export const actionsRegistryServiceRef = createServiceRef< >({ id: 'alpha.core.actionsRegistry', }); + +/** + * Read information about your current Backstage deployment. + * @alpha + */ +export const rootSystemMetadataServiceRef = createServiceRef< + import('./RootSystemMetadataService').RootSystemMetadataService +>({ + id: 'alpha.core.rootSystemMetadata', + scope: 'root', +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 541c3bd791..14eb36a3a6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -15,6 +15,7 @@ */ import { createBackend } from '@backstage/backend-defaults'; +import { rootSystemMetadataServiceFactory } from '@backstage/backend-defaults/alpha'; import { coreServices, createBackendFeatureLoader, @@ -69,7 +70,7 @@ backend.add(searchLoader); backend.add(import('@backstage/plugin-techdocs-backend')); backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@backstage/plugin-notifications-backend')); -backend.add(import('./instanceMetadata')); +backend.add(rootSystemMetadataServiceFactory); backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); backend.add(import('@backstage/plugin-mcp-actions-backend')); diff --git a/packages/backend/src/instanceMetadata.ts b/packages/backend/src/instanceMetadata.ts deleted file mode 100644 index 026fc6fd02..0000000000 --- a/packages/backend/src/instanceMetadata.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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, - createBackendPlugin, -} from '@backstage/backend-plugin-api'; - -// Example usage of the instance metadata service to log the installed plugins. -export default createBackendPlugin({ - pluginId: 'instance-metadata-logging', - register(env) { - env.registerInit({ - deps: { - instanceMetadata: coreServices.rootInstanceMetadata, - logger: coreServices.logger, - }, - async init({ instanceMetadata, logger }) { - const plugins = await instanceMetadata.getInstalledPlugins(); - logger.info( - `Installed plugins on this instance: ${plugins - .map(e => e.pluginId) - .join(', ')}`, - ); - }, - }); - }, -}); diff --git a/yarn.lock b/yarn.lock index d847deab41..03dd8dbace 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2915,6 +2915,7 @@ __metadata: express-promise-router: "npm:^4.1.0" express-rate-limit: "npm:^7.5.0" fs-extra: "npm:^11.2.0" + get-port: "npm:^5.1.1" git-url-parse: "npm:^15.0.0" helmet: "npm:^6.0.0" http-errors: "npm:^2.0.0"