Merge pull request #31053 from backstage/sennyeya/instance-metadata-update

feat: promote instance metadata
This commit is contained in:
Patrik Oldsberg
2025-11-04 13:11:40 +01:00
committed by GitHub
22 changed files with 403 additions and 91 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': minor
---
Promote `instanceMetadata` service to main entrypoint.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-gateway-backend': minor
---
Update usage of the `instanceMetadata` service.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': minor
---
Updates API for `instanceMetadata` service to return a list of plugins not features.
@@ -0,0 +1,44 @@
---
id: root-instance-metadata
title: Root Instance Metadata Service
sidebar_label: Root Instance Metadata
description: Documentation for the Root Instance Metadata service
---
The root instance metadata service provides information about the running Backstage backend instance. Currently, it provides a list of all installed backend plugins.
:::note Note
The root instance metadata service only provides information about the specific Backstage instance you're running on. In more complex deployments with multiple Backstage instances, this service will not provide a complete list of all plugins across all instances.
:::
## Using the service
The following example shows how to use the root instance metadata service in your `example` backend plugin to access the list of installed backend plugins.
```ts
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
createBackendPlugin({
pluginId: 'example',
register(env) {
env.registerInit({
deps: {
instanceMetadata: coreServices.rootInstanceMetadata,
},
async init({ instanceMetadata }) {
const plugins = instanceMetadata.getInstalledPlugins();
console.log('Installed plugins:', plugins);
},
});
},
});
```
## Dynamic plugin registration
The root instance metadata service picks up plugins that are registered at start time through a `backend.start()` call. You need to restart the running backend instance to pick up newly installed plugins.
@@ -22,9 +22,9 @@ import {
createExtensionPoint,
createBackendFeatureLoader,
ServiceRef,
coreServices,
} from '@backstage/backend-plugin-api';
import { BackendInitializer } from './BackendInitializer';
import { instanceMetadataServiceRef } from '@backstage/backend-plugin-api/alpha';
import { mockServices } from '@backstage/backend-test-utils';
const baseFactories = [
@@ -1110,7 +1110,7 @@ describe('BackendInitializer', () => {
});
it('should properly add plugins + modules to the instance metadata service', async () => {
expect.assertions(2);
expect.assertions(1);
const backend = new BackendInitializer(baseFactories);
const plugin = createBackendPlugin({
pluginId: 'test',
@@ -1126,31 +1126,25 @@ describe('BackendInitializer', () => {
register(reg) {
reg.registerInit({
deps: {
instanceMetadata: instanceMetadataServiceRef,
instanceMetadata: coreServices.rootInstanceMetadata,
},
async init({ instanceMetadata }) {
expect(instanceMetadata.getInstalledFeatures()).toEqual([
await expect(
instanceMetadata.getInstalledPlugins(),
).resolves.toEqual([
{
pluginId: 'test',
type: 'plugin',
},
{
pluginId: 'test',
moduleId: 'test',
type: 'module',
modules: [
{
moduleId: 'test',
},
],
},
{
pluginId: 'instance-metadata',
type: 'plugin',
modules: [],
},
]);
expect(instanceMetadata.getInstalledFeatures().map(String)).toEqual(
[
'plugin{pluginId=test}',
'module{moduleId=test,pluginId=test}',
'plugin{pluginId=instance-metadata}',
],
);
},
});
},
@@ -1171,6 +1165,67 @@ describe('BackendInitializer', () => {
await backend.start();
});
it('should ignore modules that do not have a matching plugin', async () => {
expect.assertions(1);
const backend = new BackendInitializer(baseFactories);
const instanceMetadataPlugin = createBackendPlugin({
pluginId: 'instance-metadata',
register(reg) {
reg.registerInit({
deps: {
instanceMetadata: coreServices.rootInstanceMetadata,
},
async init({ instanceMetadata }) {
await expect(
instanceMetadata.getInstalledPlugins(),
).resolves.toEqual([
{
pluginId: 'instance-metadata',
modules: [],
},
]);
},
});
},
});
const module = createBackendModule({
pluginId: 'test',
moduleId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
});
backend.add(module);
backend.add(instanceMetadataPlugin);
await backend.start();
});
it('should prevent writes to the instance metadata service', async () => {
expect.assertions(1);
const backend = new BackendInitializer(baseFactories);
const plugin = createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {
instanceMetadata: coreServices.rootInstanceMetadata,
},
async init({ instanceMetadata }) {
const plugins = await instanceMetadata.getInstalledPlugins();
await expect(() => {
(plugins[0] as any).pluginId = 'foo';
}).toThrow(/Cannot assign to read only property/);
},
});
},
});
backend.add(plugin);
await backend.start();
});
it('should properly wait for all modules that consume an extension point to really finish, before starting the module that provides that extension point', async () => {
expect.assertions(3);
const backend = new BackendInitializer(baseFactories);
@@ -36,14 +36,11 @@ import type {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type { InternalServiceFactory } from '../../../backend-plugin-api/src/services/system/types';
import { ForwardedError, ConflictError, assertError } from '@backstage/errors';
import {
instanceMetadataServiceRef,
BackendFeatureMeta,
} from '@backstage/backend-plugin-api/alpha';
import { DependencyGraph } from '../lib/DependencyGraph';
import { ServiceRegistry } from './ServiceRegistry';
import { createInitializationLogger } from './createInitializationLogger';
import { unwrapFeature } from './helpers';
import { deepFreeze, unwrapFeature } from './helpers';
import type { RootInstanceMetadataServicePluginInfo } from '@backstage/backend-plugin-api';
export interface BackendRegisterInit {
consumes: Set<ServiceOrExtensionPoint>;
@@ -101,56 +98,54 @@ const instanceRegistry = new (class InstanceRegistry {
};
})();
function createInstanceMetadataServiceFactory(
registrations: InternalBackendRegistrations[],
function createRootInstanceMetadataServiceFactory(
rawRegistrations: InternalBackendRegistrations[],
) {
const installedFeatures = registrations
.map(registration => {
if (registration.featureType === 'registrations') {
return registration
.getRegistrations()
.map(feature => {
if (feature.type === 'plugin') {
return Object.defineProperty(
{
type: 'plugin',
pluginId: feature.pluginId,
},
'toString',
{
enumerable: false,
configurable: true,
value: () => `plugin{pluginId=${feature.pluginId}}`,
},
);
} else if (feature.type === 'module') {
return Object.defineProperty(
{
type: 'module',
pluginId: feature.pluginId,
moduleId: feature.moduleId,
},
'toString',
{
enumerable: false,
configurable: true,
value: () =>
`module{moduleId=${feature.moduleId},pluginId=${feature.pluginId}}`,
},
);
}
// Ignore unknown feature types.
return undefined;
})
.filter(Boolean) as BackendFeatureMeta[];
}
return [];
})
.flat();
const installedPlugins: Map<string, RootInstanceMetadataServicePluginInfo> =
new Map();
const registrations = rawRegistrations
.filter(registration => registration.featureType === 'registrations')
.flatMap(registration => registration.getRegistrations());
const plugins = registrations.filter(
registration => registration.type === 'plugin',
);
const modules = registrations.filter(
registration => registration.type === 'module',
);
for (const plugin of plugins) {
const { pluginId } = plugin;
if (!installedPlugins.get(pluginId)) {
installedPlugins.set(pluginId, {
pluginId,
modules: [],
});
}
}
for (const module of modules) {
const { pluginId, moduleId } = module;
const installedPlugin = installedPlugins.get(pluginId);
if (installedPlugin) {
(installedPlugin.modules as Array<{ moduleId: string }>).push({
moduleId,
});
}
}
return createServiceFactory({
service: instanceMetadataServiceRef,
service: coreServices.rootInstanceMetadata,
deps: {},
factory: async () => ({ getInstalledFeatures: () => installedFeatures }),
factory: async () => {
console.log(installedPlugins);
const readonlyInstalledPlugins = deepFreeze([
...installedPlugins.values(),
]);
console.log(readonlyInstalledPlugins, Object.values(installedPlugins));
const instanceMetadata = {
getInstalledPlugins: () => Promise.resolve(readonlyInstalledPlugins),
};
return instanceMetadata;
},
});
}
@@ -255,7 +250,7 @@ export class BackendInitializer {
await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders);
this.#serviceRegistry.add(
createInstanceMetadataServiceFactory(this.#registrations),
createRootInstanceMetadataServiceFactory(this.#registrations),
);
// This makes sure that any uncaught errors or unhandled rejections are
@@ -34,3 +34,22 @@ export function unwrapFeature(
return feature;
}
/** @internal */
export type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
/**
* Deeply freezes an object by recursively freezing all of its properties.
* From https://gist.github.com/tkrotoff/e997cd6ff8d6cf6e51e6bb6146407fc3 +
* https://stackoverflow.com/a/69656011
*/
export function deepFreeze<T>(obj: T) {
// Can cause: "Type instantiation is excessively deep and possibly infinite."
// @ts-expect-error
Object.values(obj).forEach(
value => Object.isFrozen(value) || deepFreeze(value),
);
return Object.freeze(obj) as DeepReadonly<T>;
}
@@ -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)
```
@@ -38,6 +38,7 @@ import { eventsServiceFactory } from '@backstage/plugin-events-node';
import {
actionsRegistryServiceFactory,
actionsServiceFactory,
instanceMetadataServiceFactory,
} from '@backstage/backend-defaults/alpha';
export const defaultServiceFactories = [
@@ -65,6 +66,7 @@ export const defaultServiceFactories = [
// alpha services
actionsRegistryServiceFactory,
actionsServiceFactory,
instanceMetadataServiceFactory,
];
/**
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { instanceMetadataServiceFactory } from './instanceMetadataServiceFactory';
@@ -0,0 +1,58 @@
/*
* 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 {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import {
BackendFeatureMeta,
InstanceMetadataService,
instanceMetadataServiceRef,
} from '@backstage/backend-plugin-api/alpha';
/**
* @alpha
* @deprecated use {@link @backstage/backend-plugin-api#coreServices.rootInstanceMetadata} instead
*/
export const instanceMetadataServiceFactory = createServiceFactory({
service: instanceMetadataServiceRef,
deps: {
instanceMetadata: coreServices.rootInstanceMetadata,
},
factory: async ({ instanceMetadata }) => {
const plugins = await instanceMetadata.getInstalledPlugins();
const features: BackendFeatureMeta[] = [];
for (const plugin of plugins) {
features.push({
type: 'plugin' as const,
pluginId: plugin.pluginId,
});
for (const module of plugin.modules) {
features.push({
type: 'module' as const,
pluginId: plugin.pluginId,
moduleId: module.moduleId,
});
}
}
const service: InstanceMetadataService = {
getInstalledFeatures: () => features,
};
return service;
},
});
@@ -15,3 +15,4 @@
*/
export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { instanceMetadataServiceFactory } from './entrypoints/instanceMetadata';
+23
View File
@@ -232,6 +232,11 @@ export namespace coreServices {
const rootLogger: ServiceRef<RootLoggerService, 'root', 'singleton'>;
const scheduler: ServiceRef<SchedulerService, 'plugin', 'singleton'>;
const urlReader: ServiceRef<UrlReaderService, 'plugin', 'singleton'>;
const rootInstanceMetadata: ServiceRef<
RootInstanceMetadataService,
'plugin',
'singleton'
>;
}
// @public
@@ -578,6 +583,24 @@ export interface RootHttpRouterService {
use(path: string, handler: Handler): void;
}
// @public (undocumented)
export interface RootInstanceMetadataService {
// (undocumented)
getInstalledPlugins: () => Promise<
ReadonlyArray<RootInstanceMetadataServicePluginInfo>
>;
}
// @public (undocumented)
export interface RootInstanceMetadataServicePluginInfo {
// (undocumented)
readonly modules: ReadonlyArray<{
moduleId: string;
}>;
// (undocumented)
readonly pluginId: string;
}
// @public
export interface RootLifecycleService extends LifecycleService {
// (undocumented)
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* 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.
@@ -0,0 +1,30 @@
/*
* 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 RootInstanceMetadataServicePluginInfo {
readonly pluginId: string;
readonly modules: ReadonlyArray<{
moduleId: string;
}>;
}
/** @public */
export interface RootInstanceMetadataService {
getInstalledPlugins: () => Promise<
ReadonlyArray<RootInstanceMetadataServicePluginInfo>
>;
}
@@ -277,4 +277,15 @@ export namespace coreServices {
export const urlReader = createServiceRef<
import('./UrlReaderService').UrlReaderService
>({ id: 'core.urlReader' });
/**
* Information about the current Backstage instance.
*
* @public
*/
export const rootInstanceMetadata = createServiceRef<
import('./RootInstanceMetadataService').RootInstanceMetadataService
>({
id: 'core.rootInstanceMetadata',
});
}
@@ -85,4 +85,8 @@ export type {
UrlReaderServiceSearchResponseFile,
} from './UrlReaderService';
export type { BackstageUserInfo, UserInfoService } from './UserInfoService';
export type {
RootInstanceMetadataService,
RootInstanceMetadataServicePluginInfo,
} from './RootInstanceMetadataService';
export { coreServices } from './coreServices';
+16
View File
@@ -35,6 +35,7 @@ import { PermissionsService } from '@backstage/backend-plugin-api';
import { RootConfigService } from '@backstage/backend-plugin-api';
import { RootHealthService } from '@backstage/backend-plugin-api';
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { RootInstanceMetadataService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { RootLoggerService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
@@ -344,6 +345,21 @@ export namespace mockServices {
) => ServiceMock<RootHttpRouterService>;
}
// (undocumented)
export function rootInstanceMetadata(): RootInstanceMetadataService;
// (undocumented)
export namespace rootInstanceMetadata {
const // (undocumented)
mock: (
partialImpl?: Partial<RootInstanceMetadataService> | undefined,
) => ServiceMock<RootInstanceMetadataService>;
const // (undocumented)
factory: () => ServiceFactory<
RootInstanceMetadataService,
'plugin',
'singleton' | 'multiton'
>;
}
// (undocumented)
export namespace rootLifecycle {
const // (undocumented)
factory: () => ServiceFactory<RootLifecycleService, 'root', 'singleton'>;
@@ -34,6 +34,7 @@ import {
DatabaseService,
DiscoveryService,
HttpAuthService,
RootInstanceMetadataService,
LoggerService,
PermissionsService,
RootConfigService,
@@ -556,4 +557,19 @@ export namespace mockServices {
subscribe: jest.fn(),
}));
}
export function rootInstanceMetadata(): RootInstanceMetadataService {
return {
getInstalledPlugins: () => Promise.resolve([]),
};
}
export namespace rootInstanceMetadata {
export const mock = simpleMock(coreServices.rootInstanceMetadata, () => ({
getInstalledPlugins: jest.fn(),
}));
export const factory = simpleFactoryWithOptions(
coreServices.rootInstanceMetadata,
rootInstanceMetadata,
);
}
}
+5 -5
View File
@@ -17,21 +17,21 @@ import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { instanceMetadataServiceRef } from '@backstage/backend-plugin-api/alpha';
// Example usage of the instance metadata service to log the installed features.
// 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: instanceMetadataServiceRef,
instanceMetadata: coreServices.rootInstanceMetadata,
logger: coreServices.logger,
},
async init({ instanceMetadata, logger }) {
const plugins = await instanceMetadata.getInstalledPlugins();
logger.info(
`Installed features on this instance: ${instanceMetadata
.getInstalledFeatures()
`Installed plugins on this instance: ${plugins
.map(e => e.pluginId)
.join(', ')}`,
);
},
+3 -4
View File
@@ -18,7 +18,6 @@ import {
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import { instanceMetadataServiceRef } from '@backstage/backend-plugin-api/alpha';
import { Handler } from 'express';
/**
@@ -33,17 +32,17 @@ export const gatewayPlugin = createBackendPlugin({
deps: {
logger: coreServices.logger,
rootHttpRouter: coreServices.rootHttpRouter,
instanceMeta: instanceMetadataServiceRef,
instanceMeta: coreServices.rootInstanceMetadata,
discovery: coreServices.discovery,
},
async init({ logger, discovery, instanceMeta, rootHttpRouter }) {
rootHttpRouter.use(
'/api/:pluginId',
createRouter({
(await createRouter({
discovery,
instanceMeta,
logger,
}) as Handler,
})) as Handler,
);
},
});
+9 -10
View File
@@ -13,27 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import { InstanceMetadataService } from '@backstage/backend-plugin-api/alpha';
import {
DiscoveryService,
RootInstanceMetadataService,
LoggerService,
} from '@backstage/backend-plugin-api';
import { Request, Response, NextFunction } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { context } from '@opentelemetry/api';
import { getRPCMetadata } from '@opentelemetry/core';
export function createRouter({
export async function createRouter({
discovery,
instanceMeta,
}: {
discovery: DiscoveryService;
instanceMeta: InstanceMetadataService;
instanceMeta: RootInstanceMetadataService;
logger: LoggerService;
}) {
const localPluginIds = new Set(
instanceMeta
.getInstalledFeatures()
.filter(f => f.type === 'plugin')
.map(f => f.pluginId),
);
const plugins = await instanceMeta.getInstalledPlugins();
const localPluginIds = new Set(plugins.map(f => f.pluginId));
const proxy = createProxyMiddleware({
changeOrigin: true,