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
+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;
}