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