diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 9bc6abab79..ade61d87b1 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -48,8 +48,7 @@ "dependencies": { "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", - "@backstage/errors": "workspace:^", - "express-promise-router": "^4.1.0" + "@backstage/errors": "workspace:^" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index c954fc4f8f..1c0bf5a0ef 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -33,9 +33,6 @@ const baseFactories = [ mockServices.lifecycle.factory(), mockServices.rootLogger.factory(), mockServices.logger.factory(), - mockServices.rootConfig.factory(), - mockServices.rootHttpRouter.mock().factory, - mockServices.rootHealth.factory(), ]; function mkNoopFactory(ref: ServiceRef<{}, 'plugin'>) { @@ -811,7 +808,7 @@ describe('BackendInitializer', () => { }); it('should forward errors when modules fail to start', async () => { - const init = new BackendInitializer(baseFactories); + const init = new BackendInitializer([]); init.add(testPlugin); init.add( createBackendModule({ @@ -833,7 +830,7 @@ describe('BackendInitializer', () => { }); it('should reject duplicate plugins', async () => { - const init = new BackendInitializer(baseFactories); + const init = new BackendInitializer([]); init.add( createBackendPlugin({ pluginId: 'test', @@ -862,7 +859,7 @@ describe('BackendInitializer', () => { }); it('should reject duplicate modules', async () => { - const init = new BackendInitializer(baseFactories); + const init = new BackendInitializer([]); init.add(testPlugin); init.add( createBackendModule({ @@ -899,9 +896,6 @@ describe('BackendInitializer', () => { const init = new BackendInitializer([ mockServices.rootLifecycle.factory(), mockServices.rootLogger.factory(), - mockServices.rootHttpRouter.mock().factory, - mockServices.rootHealth.factory(), - mockServices.rootConfig.factory(), ]); init.add(testPlugin); init.add( diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index d578a4c510..f0632a37bb 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -1249,15 +1249,5 @@ export interface Config { */ plugins: string[]; }>; - - /** - * A list of deployed Backstage instances that can be crawled for discovery. - */ - instances: Array<{ - /** - * The base URL of the instance. All /.backstage/ routes should be accessible. - */ - baseUrl: string | { internal: string; external: string }; - }>; }; } diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 5586289e5f..7107b80d7e 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -33,11 +33,11 @@ "./permissions": "./src/entrypoints/permissions/index.ts", "./rootConfig": "./src/entrypoints/rootConfig/index.ts", "./rootHealth": "./src/entrypoints/rootHealth/index.ts", + "./rootSystemMetadata": "./src/entrypoints/rootSystemMetadata/index.ts", "./rootHttpRouter": "./src/entrypoints/rootHttpRouter/index.ts", "./rootLifecycle": "./src/entrypoints/rootLifecycle/index.ts", "./rootLogger": "./src/entrypoints/rootLogger/index.ts", "./scheduler": "./src/entrypoints/scheduler/index.ts", - "./systemMetadata": "./src/entrypoints/systemMetadata/index.ts", "./urlReader": "./src/entrypoints/urlReader/index.ts", "./userInfo": "./src/entrypoints/userInfo/index.ts", "./alpha": "./src/alpha/index.ts", @@ -92,15 +92,15 @@ "rootLifecycle": [ "src/entrypoints/rootLifecycle/index.ts" ], + "rootSystemMetadata": [ + "src/entrypoints/rootSystemMetadata/index.ts" + ], "rootLogger": [ "src/entrypoints/rootLogger/index.ts" ], "scheduler": [ "src/entrypoints/scheduler/index.ts" ], - "systemMetadata": [ - "src/entrypoints/systemMetadata/index.ts" - ], "urlReader": [ "src/entrypoints/urlReader/index.ts" ], @@ -198,7 +198,6 @@ "winston-transport": "^4.5.0", "yauzl": "^3.0.0", "yn": "^4.0.0", - "zen-observable": "^0.10.0", "zod": "^3.22.4", "zod-to-json-schema": "^3.20.4" }, diff --git a/packages/backend-defaults/report-alpha.api.md b/packages/backend-defaults/report-alpha.api.md index 58277b5e1e..2ca3525946 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 { InstanceMetadataService } 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 @deprecated (undocumented) +export const instanceMetadataServiceFactory: ServiceFactory< + InstanceMetadataService, + 'plugin', + 'singleton' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts index 5a4631283e..bb2e002b0d 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts @@ -150,6 +150,11 @@ export class HostDiscovery implements DiscoveryService { throw new Error('Not initialized'); }; + #resolutions: Map< + string, + Set<{ hash: string; target: { internal?: string; external?: string } }> + > = new Map(); + static fromConfig(config: RootConfigService, options?: HostDiscoveryOptions) { const discovery = new HostDiscovery(new SrvResolvers()); @@ -193,12 +198,23 @@ export class HostDiscovery implements DiscoveryService { return await resolver(pluginId); } - #updateResolvers(config: Config, defaultEndpoints?: HostDiscoveryEndpoint[]) { - this.#updateFallbackResolvers(config); - this.#updatePluginResolvers(config, defaultEndpoints); + async listResolutions() { + const _targets: Map = + new Map(); + for (const [pluginId, targets] of this.#resolutions.entries()) { + const currentTargets = [...targets.values()].map(({ target }) => ({ + ...target, + })); + if (_targets.has(pluginId)) { + _targets.set(pluginId, [..._targets.get(pluginId)!, ...currentTargets]); + } else { + _targets.set(pluginId, currentTargets); + } + } + return _targets; } - #updateFallbackResolvers(config: Config) { + getInstanceAddress(config: Config) { const backendBaseUrl = trimEnd(config.getString('backend.baseUrl'), '/'); const { @@ -220,12 +236,26 @@ export class HostDiscovery implements DiscoveryService { host = `[${host}]`; } + return { + internal: `${protocol}://${host}:${listenPort}`, + external: backendBaseUrl, + }; + } + + #updateResolvers(config: Config, defaultEndpoints?: HostDiscoveryEndpoint[]) { + this.#updateFallbackResolvers(config); + this.#updatePluginResolvers(config, defaultEndpoints); + } + + #updateFallbackResolvers(config: Config) { + const { internal, external } = this.getInstanceAddress(config); + this.#internalFallbackResolver = this.#makeResolver( - `${protocol}://${host}:${listenPort}/api/{{pluginId}}`, + `${internal}/api/{{pluginId}}`, false, ); this.#externalFallbackResolver = this.#makeResolver( - `${backendBaseUrl}/api/{{pluginId}}`, + `${external}/api/{{pluginId}}`, false, ); } @@ -264,6 +294,7 @@ export class HostDiscovery implements DiscoveryService { for (const { target, plugins } of endpoints) { let internalResolver: Resolver | undefined; let externalResolver: Resolver | undefined; + this.#addResolution(target, plugins); if (typeof target === 'string') { internalResolver = externalResolver = this.#makeResolver(target, false); @@ -293,6 +324,26 @@ export class HostDiscovery implements DiscoveryService { this.#externalResolvers = externalResolvers; } + #addResolution( + target: string | { internal?: string; external?: string }, + plugins: string[], + ) { + for (const pluginId of plugins) { + if (!this.#resolutions.has(pluginId)) { + this.#resolutions.set(pluginId, new Set()); + } + const standardizedTarget = + typeof target === 'string' + ? { external: target, internal: target } + : target; + const matchingResolution = this.#resolutions.get(pluginId)!; + const hash = JSON.stringify(standardizedTarget); + if (![...matchingResolution.values()].some(e => e.hash === hash)) { + matchingResolution.add({ target: standardizedTarget, hash }); + } + } + } + #makeResolver(urlPattern: string, allowSrv: boolean): Resolver { const withPluginId = (pluginId: string, url: string) => { return url.replace( diff --git a/packages/backend-defaults/src/entrypoints/systemMetadata/SystemMetadataService.test.ts b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts similarity index 96% rename from packages/backend-defaults/src/entrypoints/systemMetadata/SystemMetadataService.test.ts rename to packages/backend-defaults/src/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts index a273dc87ba..c6c6d6bc65 100644 --- a/packages/backend-defaults/src/entrypoints/systemMetadata/SystemMetadataService.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/RootSystemMetadataService.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Backend, createSpecializedBackend } from '@backstage/backend-app-api'; -import { systemMetadataServiceFactory } from './systemMetadataServiceFactory'; +import { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory'; import { mockServices } from '@backstage/backend-test-utils'; import getPort from 'get-port'; import { @@ -66,7 +66,7 @@ describe('SystemMetadataService', () => { instance1 = createSpecializedBackend({ defaultServiceFactories: [ ...baseFactories, - systemMetadataServiceFactory, + rootSystemMetadataServiceFactory, configFactory(instance1HttpPort), ], }); @@ -74,7 +74,7 @@ describe('SystemMetadataService', () => { instance2 = createSpecializedBackend({ defaultServiceFactories: [ ...baseFactories, - systemMetadataServiceFactory, + rootSystemMetadataServiceFactory, configFactory(instance2HttpPort), ], }); @@ -169,7 +169,7 @@ describe('SystemMetadataService', () => { instance = createSpecializedBackend({ defaultServiceFactories: [ ...baseFactories, - systemMetadataServiceFactory, + rootSystemMetadataServiceFactory, mockServices.rootConfig.factory({ data: { backend: { @@ -236,7 +236,7 @@ describe('SystemMetadataService', () => { instance = createSpecializedBackend({ defaultServiceFactories: [ ...baseFactories, - systemMetadataServiceFactory, + rootSystemMetadataServiceFactory, configFactory, ], }); diff --git a/packages/backend-defaults/src/entrypoints/systemMetadata/index.ts b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/index.ts similarity index 77% rename from packages/backend-defaults/src/entrypoints/systemMetadata/index.ts rename to packages/backend-defaults/src/entrypoints/rootSystemMetadata/index.ts index 502a217e48..cff4eccdbe 100644 --- a/packages/backend-defaults/src/entrypoints/systemMetadata/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { systemMetadataServiceFactory } from './systemMetadataServiceFactory'; -export { DefaultSystemMetadataService } from './lib/DefaultSystemMetadataService'; +export { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory'; +export { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService'; diff --git a/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts new file mode 100644 index 0000000000..e8cdb43a08 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/DefaultRootSystemMetadataService.ts @@ -0,0 +1,96 @@ +/* + * 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, + RootSystemMetadataService, + RootSystemMetadataServicePluginInfo, +} from '@backstage/backend-plugin-api'; +import { HostDiscovery } from '../../discovery'; +import {} from '@backstage/backend-plugin-api'; + +/** + * @alpha + */ +export class DefaultRootSystemMetadataService + implements RootSystemMetadataService +{ + #hostDiscovery: HostDiscovery; + #instanceMetadata: RootInstanceMetadataService; + #config: RootConfigService; + constructor(options: { + logger: LoggerService; + config: RootConfigService; + instanceMetadata: RootInstanceMetadataService; + }) { + this.#hostDiscovery = HostDiscovery.fromConfig(options.config, { + logger: options.logger, + }); + options.config.subscribe?.(() => { + this.#hostDiscovery = HostDiscovery.fromConfig(options.config, { + logger: options.logger, + }); + }); + this.#instanceMetadata = options.instanceMetadata; + this.#config = options.config; + } + + public static create(pluginEnv: { + logger: LoggerService; + config: RootConfigService; + instanceMetadata: RootInstanceMetadataService; + }) { + return new DefaultRootSystemMetadataService(pluginEnv); + } + + public async getInstalledPlugins(): Promise< + RootSystemMetadataServicePluginInfo[] + > { + const resolutions = await this.#hostDiscovery.listResolutions(); + const instanceAddress = this.#hostDiscovery.getInstanceAddress( + this.#config, + ); + const currentInstance = await this.#instanceMetadata.getInstalledPlugins(); + for (const plugin of currentInstance) { + if (!resolutions.has(plugin.pluginId)) { + resolutions.set(plugin.pluginId, []); + } + resolutions.get(plugin.pluginId)?.push(instanceAddress); + } + return Array.from(resolutions.entries()).map(([pluginId, targets]) => ({ + pluginId, + hosts: Array.from(targets).filter( + (target): target is { external: string; internal: string } => + Object.keys(target).length > 0, + ), + })); + } + + public async getHosts(): Promise< + ReadonlyArray + > { + const resolutions = await this.#hostDiscovery.listResolutions(); + const hosts = new Set(); + for (const [_, targets] of resolutions.entries()) { + for (const target of targets) { + hosts.add(target as string | { external: string; internal: string }); + } + } + return Array.from(hosts); + } +} diff --git a/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/createSystemMetadataRouter.ts b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/createSystemMetadataRouter.ts new file mode 100644 index 0000000000..eb8b835b16 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/lib/createSystemMetadataRouter.ts @@ -0,0 +1,39 @@ +/* + * 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 { LoggerService } from '@backstage/backend-plugin-api'; +import type { RootSystemMetadataService } from '@backstage/backend-plugin-api'; +import Router from 'express-promise-router'; + +export async function createSystemMetadataRouter(options: { + logger: LoggerService; + systemMetadata: RootSystemMetadataService; +}) { + const { systemMetadata } = options; + + const router = Router(); + + router.get('/hosts', async (_, res) => { + const hosts = await systemMetadata.getHosts(); + res.json({ items: hosts }); + }); + + router.get('/plugins/installed', async (_, res) => { + res.json(await systemMetadata.getInstalledPlugins()); + }); + + return router; +} diff --git a/packages/backend-defaults/src/entrypoints/systemMetadata/systemMetadataServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts similarity index 74% rename from packages/backend-defaults/src/entrypoints/systemMetadata/systemMetadataServiceFactory.ts rename to packages/backend-defaults/src/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts index bc417df898..0baa41be07 100644 --- a/packages/backend-defaults/src/entrypoints/systemMetadata/systemMetadataServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootSystemMetadata/rootSystemMetadataServiceFactory.ts @@ -18,7 +18,7 @@ import { coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { DefaultSystemMetadataService } from './lib/DefaultSystemMetadataService'; +import { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService'; import { createSystemMetadataRouter } from './lib/createSystemMetadataRouter'; /** @@ -26,17 +26,19 @@ import { createSystemMetadataRouter } from './lib/createSystemMetadataRouter'; * * @alpha */ -export const systemMetadataServiceFactory = createServiceFactory({ - service: coreServices.systemMetadata, +export const rootSystemMetadataServiceFactory = createServiceFactory({ + service: coreServices.rootSystemMetadata, deps: { logger: coreServices.rootLogger, config: coreServices.rootConfig, httpRouter: coreServices.rootHttpRouter, + instanceMetadata: coreServices.rootInstanceMetadata, }, - async factory({ logger, config, httpRouter }) { - const systemMetadata = DefaultSystemMetadataService.create({ + async factory({ logger, config, httpRouter, instanceMetadata }) { + const systemMetadata = DefaultRootSystemMetadataService.create({ logger, config, + instanceMetadata, }); const router = await createSystemMetadataRouter({ systemMetadata, logger }); diff --git a/packages/backend-defaults/src/entrypoints/systemMetadata/lib/DefaultSystemMetadataService.ts b/packages/backend-defaults/src/entrypoints/systemMetadata/lib/DefaultSystemMetadataService.ts deleted file mode 100644 index 0ce468e2be..0000000000 --- a/packages/backend-defaults/src/entrypoints/systemMetadata/lib/DefaultSystemMetadataService.ts +++ /dev/null @@ -1,187 +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 { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { - BackstageInstance, - SystemMetadataService, -} from '@backstage/backend-plugin-api'; -import { Observable } from '@backstage/types'; -import z from 'zod'; -import ObservableImpl from 'zen-observable'; - -const targetObjectSchema = z.object({ - internal: z.string(), - external: z.string(), -}); - -/** - * A basic implementation of ReactiveX behavior subjects. - * - * A subject is a convenient way to create an observable when you want - * to fan out a single value to all subscribers. - * - * The BehaviorSubject will emit the most recently emitted value or error - * whenever a new observer subscribes to the subject. - * - * See http://reactivex.io/documentation/subject.html - * - * FORKED FROM core-app-api - where should this live? - */ - -export class BehaviorSubject - implements Observable, ZenObservable.SubscriptionObserver -{ - private isClosed: boolean; - private currentValue: T; - private terminatingError: Error | undefined; - private readonly observable: Observable; - - constructor(value: T) { - this.isClosed = false; - this.currentValue = value; - this.terminatingError = undefined; - this.observable = new ObservableImpl(subscriber => { - if (this.isClosed) { - if (this.terminatingError) { - subscriber.error(this.terminatingError); - } else { - subscriber.complete(); - } - return () => {}; - } - - subscriber.next(this.currentValue); - - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); - } - - private readonly subscribers = new Set< - ZenObservable.SubscriptionObserver - >(); - - [Symbol.observable]() { - return this; - } - - get closed() { - return this.isClosed; - } - - next(value: T) { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.currentValue = value; - this.subscribers.forEach(subscriber => subscriber.next(value)); - } - - error(error: Error) { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.isClosed = true; - this.terminatingError = error; - this.subscribers.forEach(subscriber => subscriber.error(error)); - } - - complete() { - if (this.isClosed) { - throw new Error('BehaviorSubject is closed'); - } - this.isClosed = true; - this.subscribers.forEach(subscriber => subscriber.complete()); - } - - subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; - subscribe( - onNext: (value: T) => void, - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription; - subscribe( - onNext: ZenObservable.Observer | ((value: T) => void), - onError?: (error: any) => void, - onComplete?: () => void, - ): ZenObservable.Subscription { - const observer = - typeof onNext === 'function' - ? { - next: onNext, - error: onError, - complete: onComplete, - } - : onNext; - - return this.observable.subscribe(observer); - } -} - -/** - * @alpha - */ -export class DefaultSystemMetadataService implements SystemMetadataService { - private instance$: BehaviorSubject; - constructor( - private options: { logger: LoggerService; config: RootConfigService }, - ) { - const getInstances = () => { - const endpoints = - options.config.getOptionalConfigArray('discovery.instances') ?? []; - const instances: BackstageInstance[] = []; - for (const endpoint of endpoints) { - const baseUrl = endpoint.getOptional('baseUrl'); - if (baseUrl) { - if (typeof baseUrl === 'string') { - instances.push({ internalUrl: baseUrl, externalUrl: baseUrl }); - } else { - const parseAttempt = targetObjectSchema.safeParse(baseUrl); - if (parseAttempt.success) { - const { internal, external } = parseAttempt.data; - instances.push({ - internalUrl: internal, - externalUrl: external, - }); - } - } - } - } - return instances; - }; - this.instance$ = new BehaviorSubject(getInstances()); - this.options.config.subscribe?.(() => { - this.instance$.next(getInstances()); - }); - } - - public static create(pluginEnv: { - logger: LoggerService; - config: RootConfigService; - }) { - return new DefaultSystemMetadataService(pluginEnv); - } - - instances(): Observable { - return this.instance$; - } -} diff --git a/packages/backend-defaults/src/entrypoints/systemMetadata/lib/createSystemMetadataRouter.ts b/packages/backend-defaults/src/entrypoints/systemMetadata/lib/createSystemMetadataRouter.ts deleted file mode 100644 index e24870f7d0..0000000000 --- a/packages/backend-defaults/src/entrypoints/systemMetadata/lib/createSystemMetadataRouter.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { LoggerService } from '@backstage/backend-plugin-api'; -import { BackendFeatureMeta } from '@backstage/backend-plugin-api/alpha'; -import type { - BackstageInstance, - SystemMetadataService, -} from '@backstage/backend-plugin-api'; -import Router from 'express-promise-router'; - -export async function createSystemMetadataRouter(options: { - logger: LoggerService; - systemMetadata: SystemMetadataService; -}) { - const { logger, systemMetadata } = options; - - let instances: BackstageInstance[] = []; - systemMetadata.instances().subscribe({ - next: value => { - instances = value; - }, - }); - - logger.info(`Instances in this system: ${JSON.stringify(instances)}`); - - const router = Router(); - - router.get('/instances', async (_, res) => { - res.json({ items: instances }); - }); - - router.get('/features/installed', async (_, res) => { - const featurePromises = await Promise.allSettled( - instances.map(async instance => { - const response = await fetch( - `${instance.internalUrl}/.backstage/instanceMetadata/v1/features/installed`, - ); - if (response.ok) { - return { instance, response: await response.json() }; - } - throw new Error( - `Failed to fetch installed features from ${instance.internalUrl}`, - ); - }), - ); - const pluginByInstance: Record< - string, - { internalUrl: string; externalUrl: string }[] - > = {}; - for (const result of featurePromises) { - if (result.status !== 'fulfilled') { - logger.error(`Failed to fetch installed features: ${result.reason}`); - continue; - } - const instance = result.value.instance; - const installedFeatures = result.value.response - .items as BackendFeatureMeta[]; - for (const feature of installedFeatures) { - if (feature.type === 'plugin') { - if (!pluginByInstance[feature.pluginId]) { - pluginByInstance[feature.pluginId] = []; - } - pluginByInstance[feature.pluginId].push(instance); - } - } - } - res.json(pluginByInstance); - }); - - return router; -} diff --git a/packages/backend-plugin-api/src/services/definitions/SystemMetadataService.ts b/packages/backend-plugin-api/src/services/definitions/RootSystemMetadataService.ts similarity index 58% rename from packages/backend-plugin-api/src/services/definitions/SystemMetadataService.ts rename to packages/backend-plugin-api/src/services/definitions/RootSystemMetadataService.ts index 36ab1ff0e9..ded76ec65a 100644 --- a/packages/backend-plugin-api/src/services/definitions/SystemMetadataService.ts +++ b/packages/backend-plugin-api/src/services/definitions/RootSystemMetadataService.ts @@ -14,19 +14,19 @@ * limitations under the License. */ -import { Observable } from '@backstage/types'; - -/** - * @public - */ -export interface BackstageInstance { - internalUrl: string; - externalUrl: string; +/** @public */ +export interface RootSystemMetadataServicePluginInfo { + readonly pluginId: string; + readonly hosts: (string | { external: string; internal: string })[]; } -/** - * @public - */ -export interface SystemMetadataService { - instances(): Observable; +/** @public */ +export interface RootSystemMetadataService { + getInstalledPlugins: () => Promise< + ReadonlyArray + >; + + getHosts: () => Promise< + ReadonlyArray + >; } diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 0f38c0093c..e5ad69e871 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -289,15 +289,15 @@ export namespace coreServices { id: 'core.rootInstanceMetadata', scope: 'root', }); - + /** * Read information about your current Backstage deployment. * @public */ - export const systemMetadata = createServiceRef< - import('./SystemMetadataService').SystemMetadataService + export const rootSystemMetadata = createServiceRef< + import('./RootSystemMetadataService').RootSystemMetadataService >({ - id: 'core.systemMetadata', + id: 'core.rootSystemMetadata', scope: 'root', }); } diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 4e01553a76..d12395b636 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -73,9 +73,9 @@ export type { SchedulerServiceTaskScheduleDefinitionConfig, } from './SchedulerService'; export type { - BackstageInstance, - SystemMetadataService, -} from './SystemMetadataService'; + RootSystemMetadataServicePluginInfo, + RootSystemMetadataService, +} from './RootSystemMetadataService'; export type { UrlReaderService, UrlReaderServiceReadTreeOptions, diff --git a/packages/backend-test-utils/src/services/MockSystemMetadataService.test.ts b/packages/backend-test-utils/src/services/MockSystemMetadataService.test.ts deleted file mode 100644 index 8f78ec7567..0000000000 --- a/packages/backend-test-utils/src/services/MockSystemMetadataService.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { BackstageInstance } from '@backstage/backend-plugin-api'; -import { MockSystemMetadataService } from './MockSystemMetadataService'; - -describe('MockSystemMetadataService', () => { - it('should return the passed in instances', () => { - expect.assertions(1); - const instances: BackstageInstance[] = [ - { internalUrl: 'localhost:7007', externalUrl: 'external.url' }, - { internalUrl: 'localhost:7008', externalUrl: 'other.external.url' }, - ]; - const service = MockSystemMetadataService.create({ instances }); - service.instances().subscribe({ - next: value => { - expect(value).toEqual(instances); - }, - }); - }); -}); diff --git a/packages/backend-test-utils/src/services/MockSystemMetadataService.ts b/packages/backend-test-utils/src/services/MockSystemMetadataService.ts deleted file mode 100644 index eb8b39daf1..0000000000 --- a/packages/backend-test-utils/src/services/MockSystemMetadataService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { - BackstageInstance, - SystemMetadataService, -} from '@backstage/backend-plugin-api'; -import { Observable } from '@backstage/types'; -import ObservableImpl from 'zen-observable'; - -/** - * @public - */ -export class MockSystemMetadataService implements SystemMetadataService { - #instances: BackstageInstance[]; - - constructor(instances: BackstageInstance[]) { - this.#instances = instances; - } - - public static create(options: { instances: BackstageInstance[] }) { - return new MockSystemMetadataService(options.instances); - } - - instances(): Observable { - return new ObservableImpl(subscriber => { - subscriber.next(this.#instances); - subscriber.complete(); - }); - } -} diff --git a/packages/backend-test-utils/src/services/mockServices.ts b/packages/backend-test-utils/src/services/mockServices.ts index 4f89d62971..de034aae07 100644 --- a/packages/backend-test-utils/src/services/mockServices.ts +++ b/packages/backend-test-utils/src/services/mockServices.ts @@ -30,7 +30,6 @@ import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { AuthService, BackstageCredentials, - BackstageInstance, BackstageUserInfo, DatabaseService, DiscoveryService, @@ -41,7 +40,6 @@ import { SchedulerService, ServiceFactory, ServiceRef, - SystemMetadataService, UserInfoService, coreServices, createServiceFactory, @@ -63,8 +61,6 @@ import { simpleMock } from './simpleMock'; import { MockSchedulerService } from './MockSchedulerService'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { ObservableConfigProxy } from '../../../config-loader/src/sources/ObservableConfigProxy'; -import { MockSystemMetadataService } from './MockSystemMetadataService'; -import { createMockObservable } from './MockObservable'; /** @internal */ function createLoggerMock() { @@ -577,33 +573,23 @@ export namespace mockServices { rootInstanceMetadata, ); } - /** - * Creates a functional mock implementation for the - * {@link @backstage/backend-plugin-api#coreServices.systemMetadata}. - */ - export function systemMetadata(options: { - instances: BackstageInstance[]; - }): SystemMetadataService { - return MockSystemMetadataService.create(options); - } - export namespace systemMetadata { + export namespace rootSystemMetadata { /** * Creates a functional mock factory for the * {@link @backstage/backend-plugin-api#coreServices.systemMetadata}. */ export const factory = simpleFactoryWithOptions( - coreServices.systemMetadata, - systemMetadata, + coreServices.rootSystemMetadata, + rootSystemMetadata, ); /** * Creates a mock of the * {@link @backstage/backend-events-node#systemMetadata}, optionally * with some given method implementations. */ - export const mock = simpleMock(coreServices.systemMetadata, () => ({ - instances: jest - .fn() - .mockReturnValue(createMockObservable([])), + export const mock = simpleMock(coreServices.rootSystemMetadata, () => ({ + getInstalledPlugins: jest.fn(), + getHosts: jest.fn(), })); } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a6f9b7b3e9..f5a14e150a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -19,7 +19,7 @@ import { coreServices, createBackendFeatureLoader, } from '@backstage/backend-plugin-api'; -import { systemMetadataServiceFactory } from '@backstage/backend-defaults/alpha'; +import { rootSystemMetadataServiceFactory } from '@backstage/backend-defaults/rootSystemMetadata'; const backend = createBackend(); @@ -70,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(systemMetadataServiceFactory); +backend.add(rootSystemMetadataServiceFactory); backend.add(import('@backstage/plugin-events-backend-module-google-pubsub')); backend.add(import('@backstage/plugin-mcp-actions-backend'));