diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 18b0adaee7..3ce4ba2ee5 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -1093,7 +1093,9 @@ describe('BackendInitializer', () => { instanceMetadata: coreServices.instanceMetadata, }, async init({ instanceMetadata }) { - expect(instanceMetadata.getInstalledPlugins()).toEqual([ + await expect( + instanceMetadata.getInstalledPlugins(), + ).resolves.toEqual([ { pluginId: 'test', modules: [ @@ -1127,6 +1129,29 @@ describe('BackendInitializer', () => { 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.instanceMetadata, + }, + 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); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 6d3939a4b5..080f6c7c69 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -39,7 +39,7 @@ import { ForwardedError, ConflictError, assertError } from '@backstage/errors'; import { DependencyGraph } from '../lib/DependencyGraph'; import { ServiceRegistry } from './ServiceRegistry'; import { createInitializationLogger } from './createInitializationLogger'; -import { unwrapFeature } from './helpers'; +import { deepFreeze, unwrapFeature } from './helpers'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { InstanceMetadataServicePluginInfo } from '../../../backend-plugin-api/src/services/definitions/InstanceMetadataService'; @@ -135,13 +135,17 @@ function createInstanceMetadataServiceFactory( logger: coreServices.rootLogger, }, factory: async ({ logger }) => { + const readonlyInstalledPlugins = deepFreeze( + Object.values(installedPlugins), + ); const instanceMetadata = { - getInstalledPlugins: () => Object.values(installedPlugins), + getInstalledPlugins: () => Promise.resolve(readonlyInstalledPlugins), }; + const plugins = await instanceMetadata.getInstalledPlugins(); + logger.info( - `Installed plugins on this instance: ${instanceMetadata - .getInstalledPlugins() + `Installed plugins on this instance: ${plugins .map(p => p.pluginId) .join(', ')}`, ); diff --git a/packages/backend-app-api/src/wiring/helpers.ts b/packages/backend-app-api/src/wiring/helpers.ts index ffb7e3b079..e67c9a1125 100644 --- a/packages/backend-app-api/src/wiring/helpers.ts +++ b/packages/backend-app-api/src/wiring/helpers.ts @@ -34,3 +34,29 @@ export function unwrapFeature( return feature; } + +/** @internal */ +export type DeepReadonly = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]; +}; + +/** + * Deeply freezes an object by recursively freezing all of its properties. + * + * - https://gist.github.com/tkrotoff/e997cd6ff8d6cf6e51e6bb6146407fc3 + * - https://stackoverflow.com/a/69656011 + * + * FIXME Should be part of Lodash and related: https://github.com/Maggi64/moderndash/issues/139 + * + * Does not work with Set and Map: https://stackoverflow.com/q/31509175 + */ +export function deepFreeze< + T, + // Can cause: "Type instantiation is excessively deep and possibly infinite." +>(obj: T) { + // @ts-expect-error + Object.values(obj).forEach( + value => Object.isFrozen(value) || deepFreeze(value), + ); + return Object.freeze(obj) as DeepReadonly; +} diff --git a/packages/backend-plugin-api/src/services/definitions/InstanceMetadataService.ts b/packages/backend-plugin-api/src/services/definitions/InstanceMetadataService.ts index 78d6f8356c..7e3b3b03d1 100644 --- a/packages/backend-plugin-api/src/services/definitions/InstanceMetadataService.ts +++ b/packages/backend-plugin-api/src/services/definitions/InstanceMetadataService.ts @@ -22,7 +22,14 @@ export interface InstanceMetadataServicePluginInfo { }[]; } +/** @internal */ +export type DeepReadonly = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]; +}; + /** @public */ export interface InstanceMetadataService { - getInstalledPlugins: () => readonly InstanceMetadataServicePluginInfo[]; + getInstalledPlugins: () => Promise< + ReadonlyArray> + >; } diff --git a/plugins/gateway-backend/src/plugin.ts b/plugins/gateway-backend/src/plugin.ts index 391efb6852..efd7a372de 100644 --- a/plugins/gateway-backend/src/plugin.ts +++ b/plugins/gateway-backend/src/plugin.ts @@ -38,11 +38,11 @@ export const gatewayPlugin = createBackendPlugin({ async init({ logger, discovery, instanceMeta, rootHttpRouter }) { rootHttpRouter.use( '/api/:pluginId', - createRouter({ + (await createRouter({ discovery, instanceMeta, logger, - }) as Handler, + })) as Handler, ); }, }); diff --git a/plugins/gateway-backend/src/router.ts b/plugins/gateway-backend/src/router.ts index 4b041ef06d..7f8c898761 100644 --- a/plugins/gateway-backend/src/router.ts +++ b/plugins/gateway-backend/src/router.ts @@ -23,7 +23,7 @@ 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, }: { @@ -31,9 +31,8 @@ export function createRouter({ instanceMeta: InstanceMetadataService; logger: LoggerService; }) { - const localPluginIds = new Set( - instanceMeta.getInstalledPlugins().map(f => f.pluginId), - ); + const plugins = await instanceMeta.getInstalledPlugins(); + const localPluginIds = new Set(plugins.map(f => f.pluginId)); const proxy = createProxyMiddleware({ changeOrigin: true,