Merge pull request #28129 from backstage/sennyeya/deployment-discovery

feat: add a new system metadata service
This commit is contained in:
Patrik Oldsberg
2025-12-09 12:48:52 +01:00
committed by GitHub
17 changed files with 409 additions and 61 deletions
+6
View File
@@ -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.
+1
View File
@@ -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",
@@ -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)
```
@@ -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',
},
]);
});
});
});
@@ -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';
@@ -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;
}
}
@@ -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,
});
},
});
@@ -16,3 +16,4 @@
export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
@@ -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<string>;
@@ -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<string, Resolver> = new Map();
@@ -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;
}
@@ -103,5 +103,26 @@ export const actionsServiceRef: ServiceRef<
'singleton'
>;
// @public (undocumented)
export interface RootSystemMetadataService {
// (undocumented)
getInstalledPlugins: () => Promise<
ReadonlyArray<RootSystemMetadataServicePluginInfo>
>;
}
// @public (undocumented)
export interface RootSystemMetadataServicePluginInfo {
// (undocumented)
readonly pluginId: string;
}
// @alpha
export const rootSystemMetadataServiceRef: ServiceRef<
RootSystemMetadataService,
'root',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -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<RootSystemMetadataServicePluginInfo>
>;
}
+10 -1
View File
@@ -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';
@@ -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',
});
+2 -1
View File
@@ -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'));
-40
View File
@@ -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(', ')}`,
);
},
});
},
});
+1
View File
@@ -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"