From e6c05502d558bfbe96ac5028963dd58cb3171fea Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 23 Sep 2024 16:23:06 +0200 Subject: [PATCH] refactor(backend-dynamic-feature-service): better load failure management... ... and other small enhancements (e.g. ability to get the `ScannedPluginPackage` of a loaded plugin). Signed-off-by: David Festal --- .changeset/kind-avocados-speak.md | 8 + package.json | 1 + .../report.api.md | 154 ++++---- .../src/manager/plugin-manager.test.ts | 341 ++++++++++++++++-- .../src/manager/plugin-manager.ts | 135 ++++--- .../src/manager/types.ts | 11 +- yarn.lock | 1 + 7 files changed, 498 insertions(+), 153 deletions(-) create mode 100644 .changeset/kind-avocados-speak.md diff --git a/.changeset/kind-avocados-speak.md b/.changeset/kind-avocados-speak.md new file mode 100644 index 0000000000..b57d271907 --- /dev/null +++ b/.changeset/kind-avocados-speak.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Enhance the API of the `DynamicPluginProvider` (available as a service) to: + +- expose the new `getScannedPackage()` method that returns the `ScannedPluginPackage` from which a given plugin has been loaded, +- add an optional `includeFailed` argument in the plugins list retrieval methods, to include the plugins that could be successfully loaded (`false` by default). diff --git a/package.json b/package.json index 425b1c1a38..4826bcd56b 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch" }, "dependencies": { + "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@manypkg/get-packages": "^1.1.3", "@types/global-agent": "^2.1.3", diff --git a/packages/backend-dynamic-feature-service/report.api.md b/packages/backend-dynamic-feature-service/report.api.md index 9170fedf43..615b053869 100644 --- a/packages/backend-dynamic-feature-service/report.api.md +++ b/packages/backend-dynamic-feature-service/report.api.md @@ -33,11 +33,12 @@ import { ServiceRef } from '@backstage/backend-plugin-api'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { TokenManager } from '@backstage/backend-common'; import { UrlReaderService } from '@backstage/backend-plugin-api'; +import { WinstonLoggerOptions } from '@backstage/backend-defaults/rootLogger'; // @public (undocumented) export interface BackendDynamicPlugin extends BaseDynamicPlugin { // (undocumented) - installer: BackendDynamicPluginInstaller; + installer?: BackendDynamicPluginInstaller; // (undocumented) platform: 'node'; } @@ -50,11 +51,13 @@ export type BackendDynamicPluginInstaller = // @public (undocumented) export interface BackendPluginProvider { // (undocumented) - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; } // @public (undocumented) export interface BaseDynamicPlugin { + // (undocumented) + failure?: string; // (undocumented) name: string; // (undocumented) @@ -75,15 +78,17 @@ export class DynamicPluginManager implements DynamicPluginProvider { // (undocumented) get availablePackages(): ScannedPluginPackage[]; // (undocumented) - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; // (undocumented) static create( options: DynamicPluginManagerOptions, ): Promise; // (undocumented) - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; // (undocumented) - plugins(): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; + // (undocumented) + plugins(includeFailed?: boolean): DynamicPlugin[]; } // @public (undocumented) @@ -103,20 +108,19 @@ export interface DynamicPluginProvider extends FrontendPluginProvider, BackendPluginProvider { // (undocumented) - plugins(): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; + // (undocumented) + plugins(includeFailed?: boolean): DynamicPlugin[]; } // @public (undocumented) export interface DynamicPluginsFactoryOptions { // (undocumented) - moduleLoader?(logger: LoggerService): ModuleLoader; + moduleLoader?(logger: LoggerService): ModuleLoader | Promise; } -// @public -export const dynamicPluginsFeatureDiscoveryLoader: (( - options?: DynamicPluginsFactoryOptions, -) => BackendFeature) & - BackendFeature; +// @public @deprecated (undocumented) +export const dynamicPluginsFeatureDiscoveryLoader: BackendFeature; // @public @deprecated (undocumented) export const dynamicPluginsFeatureDiscoveryServiceFactory: ServiceFactory< @@ -125,16 +129,32 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: ServiceFactory< 'singleton' >; +// @public +export const dynamicPluginsFeatureLoader: (( + options?: DynamicPluginsFeatureLoaderOptions, +) => BackendFeature) & + BackendFeature; + // @public (undocumented) +export type DynamicPluginsFeatureLoaderOptions = DynamicPluginsFactoryOptions & + DynamicPluginsSchemasOptions & + DynamicPluginsRootLoggerFactoryOptions; + +// @public @deprecated (undocumented) export const dynamicPluginsFrontendSchemas: BackendFeature; // @public (undocumented) -export const dynamicPluginsRootLoggerServiceFactory: ServiceFactory< - RootLoggerService, - 'root', - 'singleton' +export type DynamicPluginsRootLoggerFactoryOptions = Omit< + WinstonLoggerOptions, + 'meta' >; +// @public @deprecated (undocumented) +export const dynamicPluginsRootLoggerServiceFactory: (( + options?: DynamicPluginsRootLoggerFactoryOptions, +) => ServiceFactory) & + ServiceFactory; + // @public (undocumented) export interface DynamicPluginsSchemasOptions { schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; @@ -148,31 +168,24 @@ export interface DynamicPluginsSchemasService { }>; } -// @public (undocumented) -export const dynamicPluginsSchemasServiceFactory: ServiceFactory< - DynamicPluginsSchemasService, - 'root', - 'singleton' ->; - -// @public (undocumented) -export const dynamicPluginsSchemasServiceFactoryWithOptions: ( +// @public @deprecated (undocumented) +export const dynamicPluginsSchemasServiceFactory: (( options?: DynamicPluginsSchemasOptions, -) => ServiceFactory; +) => ServiceFactory) & + ServiceFactory; // @public @deprecated (undocumented) -export const dynamicPluginsServiceFactory: ServiceFactory< - DynamicPluginProvider, - 'root', - 'singleton' ->; +export const dynamicPluginsServiceFactory: (( + options?: DynamicPluginsFactoryOptions, +) => ServiceFactory) & + ServiceFactory; // @public @deprecated (undocumented) export const dynamicPluginsServiceFactoryWithOptions: ( options?: DynamicPluginsFactoryOptions, ) => ServiceFactory; -// @public @deprecated (undocumented) +// @public (undocumented) export const dynamicPluginsServiceRef: ServiceRef< DynamicPluginProvider, 'root', @@ -188,7 +201,7 @@ export interface FrontendDynamicPlugin extends BaseDynamicPlugin { // @public (undocumented) export interface FrontendPluginProvider { // (undocumented) - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; } // @public (undocumented) @@ -278,6 +291,7 @@ export interface ScannedPluginPackage { // Warnings were encountered during analysis: // +// src/features/features.d.ts:7:1 - (ae-undocumented) Missing documentation for "DynamicPluginsFeatureLoaderOptions". // src/loader/types.d.ts:4:1 - (ae-undocumented) Missing documentation for "ModuleLoader". // src/loader/types.d.ts:5:5 - (ae-undocumented) Missing documentation for "bootstrap". // src/loader/types.d.ts:6:5 - (ae-undocumented) Missing documentation for "load". @@ -293,54 +307,58 @@ export interface ScannedPluginPackage { // src/manager/plugin-manager.d.ts:31:5 - (ae-undocumented) Missing documentation for "backendPlugins". // src/manager/plugin-manager.d.ts:32:5 - (ae-undocumented) Missing documentation for "frontendPlugins". // src/manager/plugin-manager.d.ts:33:5 - (ae-undocumented) Missing documentation for "plugins". +// src/manager/plugin-manager.d.ts:34:5 - (ae-undocumented) Missing documentation for "getScannedPackage". // src/manager/plugin-manager.d.ts:39:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceRef". // src/manager/plugin-manager.d.ts:43:1 - (ae-undocumented) Missing documentation for "DynamicPluginsFactoryOptions". // src/manager/plugin-manager.d.ts:44:5 - (ae-undocumented) Missing documentation for "moduleLoader". // src/manager/plugin-manager.d.ts:50:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceFactoryWithOptions". // src/manager/plugin-manager.d.ts:55:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceFactory". // src/manager/plugin-manager.d.ts:60:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFeatureDiscoveryServiceFactory". -// src/manager/types.d.ts:27:1 - (ae-undocumented) Missing documentation for "LegacyPluginEnvironment". -// src/manager/types.d.ts:45:1 - (ae-undocumented) Missing documentation for "DynamicPluginProvider". -// src/manager/types.d.ts:46:5 - (ae-undocumented) Missing documentation for "plugins". -// src/manager/types.d.ts:51:1 - (ae-undocumented) Missing documentation for "BackendPluginProvider". -// src/manager/types.d.ts:52:5 - (ae-undocumented) Missing documentation for "backendPlugins". -// src/manager/types.d.ts:57:1 - (ae-undocumented) Missing documentation for "FrontendPluginProvider". -// src/manager/types.d.ts:58:5 - (ae-undocumented) Missing documentation for "frontendPlugins". -// src/manager/types.d.ts:63:1 - (ae-undocumented) Missing documentation for "BaseDynamicPlugin". -// src/manager/types.d.ts:64:5 - (ae-undocumented) Missing documentation for "name". -// src/manager/types.d.ts:65:5 - (ae-undocumented) Missing documentation for "version". -// src/manager/types.d.ts:66:5 - (ae-undocumented) Missing documentation for "role". -// src/manager/types.d.ts:67:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:72:1 - (ae-undocumented) Missing documentation for "DynamicPlugin". -// src/manager/types.d.ts:76:1 - (ae-undocumented) Missing documentation for "FrontendDynamicPlugin". -// src/manager/types.d.ts:77:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:82:1 - (ae-undocumented) Missing documentation for "BackendDynamicPlugin". -// src/manager/types.d.ts:83:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:84:5 - (ae-undocumented) Missing documentation for "installer". -// src/manager/types.d.ts:89:1 - (ae-undocumented) Missing documentation for "BackendDynamicPluginInstaller". -// src/manager/types.d.ts:93:1 - (ae-undocumented) Missing documentation for "NewBackendPluginInstaller". -// src/manager/types.d.ts:94:5 - (ae-undocumented) Missing documentation for "kind". -// src/manager/types.d.ts:95:5 - (ae-undocumented) Missing documentation for "install". -// src/manager/types.d.ts:108:1 - (ae-undocumented) Missing documentation for "LegacyBackendPluginInstaller". -// src/manager/types.d.ts:109:5 - (ae-undocumented) Missing documentation for "kind". -// src/manager/types.d.ts:110:5 - (ae-undocumented) Missing documentation for "router". -// src/manager/types.d.ts:114:5 - (ae-undocumented) Missing documentation for "catalog". -// src/manager/types.d.ts:115:5 - (ae-undocumented) Missing documentation for "scaffolder". -// src/manager/types.d.ts:116:5 - (ae-undocumented) Missing documentation for "search". -// src/manager/types.d.ts:117:5 - (ae-undocumented) Missing documentation for "events". -// src/manager/types.d.ts:118:5 - (ae-undocumented) Missing documentation for "permissions". -// src/manager/types.d.ts:125:1 - (ae-undocumented) Missing documentation for "isBackendDynamicPluginInstaller". +// src/manager/plugin-manager.d.ts:65:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFeatureDiscoveryLoader". +// src/manager/types.d.ts:28:1 - (ae-undocumented) Missing documentation for "LegacyPluginEnvironment". +// src/manager/types.d.ts:46:1 - (ae-undocumented) Missing documentation for "DynamicPluginProvider". +// src/manager/types.d.ts:47:5 - (ae-undocumented) Missing documentation for "plugins". +// src/manager/types.d.ts:48:5 - (ae-undocumented) Missing documentation for "getScannedPackage". +// src/manager/types.d.ts:53:1 - (ae-undocumented) Missing documentation for "BackendPluginProvider". +// src/manager/types.d.ts:54:5 - (ae-undocumented) Missing documentation for "backendPlugins". +// src/manager/types.d.ts:59:1 - (ae-undocumented) Missing documentation for "FrontendPluginProvider". +// src/manager/types.d.ts:60:5 - (ae-undocumented) Missing documentation for "frontendPlugins". +// src/manager/types.d.ts:65:1 - (ae-undocumented) Missing documentation for "BaseDynamicPlugin". +// src/manager/types.d.ts:66:5 - (ae-undocumented) Missing documentation for "name". +// src/manager/types.d.ts:67:5 - (ae-undocumented) Missing documentation for "version". +// src/manager/types.d.ts:68:5 - (ae-undocumented) Missing documentation for "role". +// src/manager/types.d.ts:69:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:70:5 - (ae-undocumented) Missing documentation for "failure". +// src/manager/types.d.ts:75:1 - (ae-undocumented) Missing documentation for "DynamicPlugin". +// src/manager/types.d.ts:79:1 - (ae-undocumented) Missing documentation for "FrontendDynamicPlugin". +// src/manager/types.d.ts:80:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:85:1 - (ae-undocumented) Missing documentation for "BackendDynamicPlugin". +// src/manager/types.d.ts:86:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:87:5 - (ae-undocumented) Missing documentation for "installer". +// src/manager/types.d.ts:92:1 - (ae-undocumented) Missing documentation for "BackendDynamicPluginInstaller". +// src/manager/types.d.ts:96:1 - (ae-undocumented) Missing documentation for "NewBackendPluginInstaller". +// src/manager/types.d.ts:97:5 - (ae-undocumented) Missing documentation for "kind". +// src/manager/types.d.ts:98:5 - (ae-undocumented) Missing documentation for "install". +// src/manager/types.d.ts:111:1 - (ae-undocumented) Missing documentation for "LegacyBackendPluginInstaller". +// src/manager/types.d.ts:112:5 - (ae-undocumented) Missing documentation for "kind". +// src/manager/types.d.ts:113:5 - (ae-undocumented) Missing documentation for "router". +// src/manager/types.d.ts:117:5 - (ae-undocumented) Missing documentation for "catalog". +// src/manager/types.d.ts:118:5 - (ae-undocumented) Missing documentation for "scaffolder". +// src/manager/types.d.ts:119:5 - (ae-undocumented) Missing documentation for "search". +// src/manager/types.d.ts:120:5 - (ae-undocumented) Missing documentation for "events". +// src/manager/types.d.ts:121:5 - (ae-undocumented) Missing documentation for "permissions". +// src/manager/types.d.ts:128:1 - (ae-undocumented) Missing documentation for "isBackendDynamicPluginInstaller". // src/scanner/types.d.ts:5:1 - (ae-undocumented) Missing documentation for "ScannedPluginPackage". // src/scanner/types.d.ts:6:5 - (ae-undocumented) Missing documentation for "location". // src/scanner/types.d.ts:7:5 - (ae-undocumented) Missing documentation for "manifest". // src/scanner/types.d.ts:12:1 - (ae-undocumented) Missing documentation for "ScannedPluginManifest". -// src/schemas/appBackendModule.d.ts:2:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFrontendSchemas". -// src/schemas/rootLoggerServiceFactory.d.ts:2:22 - (ae-undocumented) Missing documentation for "dynamicPluginsRootLoggerServiceFactory". +// src/schemas/frontend.d.ts:5:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFrontendSchemas". +// src/schemas/rootLogger.d.ts:5:1 - (ae-undocumented) Missing documentation for "DynamicPluginsRootLoggerFactoryOptions". +// src/schemas/rootLogger.d.ts:10:22 - (ae-undocumented) Missing documentation for "dynamicPluginsRootLoggerServiceFactory". // src/schemas/schemas.d.ts:7:1 - (ae-undocumented) Missing documentation for "DynamicPluginsSchemasService". // src/schemas/schemas.d.ts:8:5 - (ae-undocumented) Missing documentation for "addDynamicPluginsSchemas". // src/schemas/schemas.d.ts:21:1 - (ae-undocumented) Missing documentation for "DynamicPluginsSchemasOptions". -// src/schemas/schemas.d.ts:36:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactoryWithOptions". -// src/schemas/schemas.d.ts:40:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactory". +// src/schemas/schemas.d.ts:37:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactory". // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts index 5c01a3dfe5..6c129c07e2 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts @@ -44,6 +44,7 @@ import { PluginScanner } from '../scanner/plugin-scanner'; import { findPaths } from '@backstage/cli-common'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle'; +import { PackageRole } from '@backstage/cli-node'; describe('backend-dynamic-feature-service', () => { const mockDir = createMockDirectory(); @@ -299,6 +300,29 @@ describe('backend-dynamic-feature-service', () => { ); }, }, + { + name: 'should ignore plugin package with incompatible role', + packageManifest: { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'node-library', + }, + main: 'dist/index.cjs.js', + }, + expectedLogs(location) { + return { + infos: [ + { + message: `skipping dynamic plugin package 'backend-dynamic-plugin-test' from '${location}': incompatible role 'node-library'`, + }, + ], + }; + }, + checkLoadedPlugins(plugins) { + expect(plugins).toMatchObject([]); + }, + }, { name: 'should fail when no index file', packageManifest: { @@ -342,7 +366,17 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: expect.stringMatching( + `^Error: Cannot find module '[^']*' from .*`, + ), + }, + ]); }, }, { @@ -369,7 +403,15 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + }, + ]); }, }, { @@ -397,7 +439,15 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + }, + ]); }, }, { @@ -428,7 +478,17 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: expect.stringMatching( + `^SyntaxError: Unexpected identifier.*`, + ), + }, + ]); }, }, { @@ -495,6 +555,27 @@ describe('backend-dynamic-feature-service', () => { ]); }, }, + { + name: 'should successfully load a frontend plugin (experimental dynamic container)', + packageManifest: { + name: 'frontend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'frontend-dynamic-container' as PackageRole, + }, + main: 'dist/index.esm.js', + }, + checkLoadedPlugins(plugins) { + expect(plugins).toMatchObject([ + { + name: 'frontend-dynamic-plugin-test', + version: '0.0.0', + role: 'frontend-dynamic-container', + platform: 'web', + }, + ]); + }, + }, ])('$name', async (tc: TestCase): Promise => { const plugin: ScannedPluginPackage = { location: url.pathToFileURL(mockDir.resolve(randomUUID())), @@ -538,33 +619,54 @@ describe('backend-dynamic-feature-service', () => { }); }); - describe('backendPlugins', () => { + describe('plugin getters', () => { + const plugins: BaseDynamicPlugin[] = [ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]; + it('should return only backend plugins and modules', async () => { const logger = new MockedLogger(); const pluginManager = new (DynamicPluginManager as any)( logger, [], ) as DynamicPluginManager; - const plugins: BaseDynamicPlugin[] = [ - { - name: 'a-frontend-plugin', - platform: 'web', - role: 'frontend-plugin', - version: '0.0.0', - }, - { - name: 'a-backend-plugin', - platform: 'node', - role: 'backend-plugin', - version: '0.0.0', - }, - { - name: 'a-backend-module', - platform: 'node', - role: 'backend-plugin-module', - version: '0.0.0', - }, - ]; (pluginManager as any)._plugins = plugins; expect(pluginManager.backendPlugins()).toEqual([ { @@ -580,17 +682,109 @@ describe('backend-dynamic-feature-service', () => { version: '0.0.0', }, ]); + expect(pluginManager.backendPlugins(false)).toEqual([ + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.backendPlugins(true)).toEqual([ + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]); }); - }); - describe('frontendPlugins', () => { it('should return only frontend plugins', async () => { const logger = new MockedLogger(); const pluginManager = new (DynamicPluginManager as any)( logger, [], ) as DynamicPluginManager; - const plugins: BaseDynamicPlugin[] = [ + (pluginManager as any)._plugins = plugins; + expect(pluginManager.frontendPlugins()).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.frontendPlugins(false)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.frontendPlugins(true)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + ]); + }); + + it('should return all plugins', async () => { + const logger = new MockedLogger(); + const pluginManager = new (DynamicPluginManager as any)( + logger, + [], + ) as DynamicPluginManager; + (pluginManager as any)._plugins = plugins; + expect(pluginManager.plugins()).toEqual([ { name: 'a-frontend-plugin', platform: 'web', @@ -615,9 +809,8 @@ describe('backend-dynamic-feature-service', () => { role: 'backend-plugin-module', version: '0.0.0', }, - ]; - (pluginManager as any)._plugins = plugins; - expect(pluginManager.frontendPlugins()).toEqual([ + ]); + expect(pluginManager.plugins(false)).toEqual([ { name: 'a-frontend-plugin', platform: 'web', @@ -630,7 +823,93 @@ describe('backend-dynamic-feature-service', () => { role: 'frontend-plugin-module', version: '0.0.0', }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, ]); + expect(pluginManager.plugins(true)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]); + }); + }); + + describe('get scanned package', () => { + it('should return the scanned package of the plugin', async () => { + const logger = new MockedLogger(); + const packageFolder = mockDir.resolve(randomUUID()); + const scannedPackage = { + manifest: { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'backend-plugin', + }, + main: 'dist/index.cjs.js', + }, + location: url.pathToFileURL(packageFolder), + }; + const plugin = { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + installer: { + kind: 'new', + }, + } as BackendDynamicPlugin; + + const pluginManager = new (DynamicPluginManager as any)(logger, [ + scannedPackage, + ]) as DynamicPluginManager; + (pluginManager as any)._plugins = [plugin]; + + expect(pluginManager.getScannedPackage(plugin)).toEqual(scannedPackage); }); }); diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index fef0b6000d..5b88c0619b 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -34,7 +34,7 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { PackageRoles } from '@backstage/cli-node'; +import { PackageRole, PackageRoles } from '@backstage/cli-node'; import { findPaths } from '@backstage/cli-common'; import path from 'path'; import * as fs from 'fs'; @@ -104,7 +104,7 @@ export class DynamicPluginManager implements DynamicPluginProvider { private constructor( private readonly logger: LoggerService, - private packages: ScannedPluginPackage[], + private readonly packages: ScannedPluginPackage[], private readonly moduleLoader: ModuleLoader, ) { this._plugins = []; @@ -123,26 +123,39 @@ export class DynamicPluginManager implements DynamicPluginProvider { const loadedPlugins: DynamicPlugin[] = []; for (const scannedPlugin of this.packages) { - const platform = PackageRoles.getRoleInfo( - scannedPlugin.manifest.backstage.role, - ).platform; + const role = scannedPlugin.manifest.backstage.role; + const platform = PackageRoles.getRoleInfo(role).platform; + const isPlugin = + role.endsWith('-plugin') || + role.endsWith('-plugin-module') || + role === ('frontend-dynamic-container' as PackageRole); - if ( - platform === 'node' && - scannedPlugin.manifest.backstage.role.includes('-plugin') - ) { - const plugin = await this.loadBackendPlugin(scannedPlugin); - if (plugin !== undefined) { - loadedPlugins.push(plugin); - } - } else { - loadedPlugins.push({ - name: scannedPlugin.manifest.name, - version: scannedPlugin.manifest.version, - role: scannedPlugin.manifest.backstage.role, - platform: 'web', - // TODO(davidfestal): add required front-end plugin information here. - }); + if (!isPlugin) { + this.logger.info( + `skipping dynamic plugin package '${scannedPlugin.manifest.name}' from '${scannedPlugin.location}': incompatible role '${role}'`, + ); + continue; + } + + switch (platform) { + case 'node': + loadedPlugins.push(await this.loadBackendPlugin(scannedPlugin)); + break; + + case 'web': + loadedPlugins.push({ + name: scannedPlugin.manifest.name, + version: scannedPlugin.manifest.version, + role: scannedPlugin.manifest.backstage.role, + platform: 'web', + // TODO(davidfestal): add required front-end plugin information here. + }); + break; + + default: + this.logger.info( + `skipping dynamic plugin package '${scannedPlugin.manifest.name}' from '${scannedPlugin.location}': unrelated platform '${platform}'`, + ); } } return loadedPlugins; @@ -150,66 +163,88 @@ export class DynamicPluginManager implements DynamicPluginProvider { private async loadBackendPlugin( plugin: ScannedPluginPackage, - ): Promise { + ): Promise { const packagePath = url.fileURLToPath( `${plugin.location}/${plugin.manifest.main}`, ); + const dynamicPlugin: BackendDynamicPlugin = { + name: plugin.manifest.name, + version: plugin.manifest.version, + platform: 'node', + role: plugin.manifest.backstage.role, + }; + try { const pluginModule = await this.moduleLoader.load(packagePath); - let dynamicPluginInstaller; if (isBackendFeature(pluginModule.default)) { - dynamicPluginInstaller = { + dynamicPlugin.installer = { kind: 'new', install: () => pluginModule.default, }; } else if (isBackendFeatureFactory(pluginModule.default)) { - dynamicPluginInstaller = { + dynamicPlugin.installer = { kind: 'new', install: pluginModule.default, }; - } else { - dynamicPluginInstaller = pluginModule.dynamicPluginInstaller; + } else if ( + isBackendDynamicPluginInstaller(pluginModule.dynamicPluginInstaller) + ) { + dynamicPlugin.installer = pluginModule.dynamicPluginInstaller; } - if (!isBackendDynamicPluginInstaller(dynamicPluginInstaller)) { - this.logger.error( - `dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + if (dynamicPlugin.installer) { + this.logger.info( + `loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, + ); + } else { + dynamicPlugin.failure = `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`; + this.logger.error( + `dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': ${dynamicPlugin.failure}`, ); - return undefined; } - this.logger.info( - `loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, - ); - return { - name: plugin.manifest.name, - version: plugin.manifest.version, - platform: 'node', - role: plugin.manifest.backstage.role, - installer: dynamicPluginInstaller, - }; + return dynamicPlugin; } catch (error) { + const typedError = + typeof error === 'object' && 'message' in error && 'name' in error + ? error + : new Error(error); + dynamicPlugin.failure = `${typedError.name}: ${typedError.message}`; this.logger.error( `an error occurred while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, - error, + typedError, ); - return undefined; + return dynamicPlugin; } } - backendPlugins(): BackendDynamicPlugin[] { - return this._plugins.filter( + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[] { + return this.plugins(includeFailed).filter( (p): p is BackendDynamicPlugin => p.platform === 'node', ); } - frontendPlugins(): FrontendDynamicPlugin[] { - return this._plugins.filter( + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[] { + return this.plugins(includeFailed).filter( (p): p is FrontendDynamicPlugin => p.platform === 'web', ); } - plugins(): DynamicPlugin[] { - return this._plugins; + plugins(includeFailed?: boolean): DynamicPlugin[] { + return this._plugins.filter(p => includeFailed || !p.failure); + } + + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage { + const pkg = this.packages.find( + p => + p.manifest.name === plugin.name && + p.manifest.version === plugin.version, + ); + if (pkg === undefined) { + throw new Error( + `The scanned package of a dynamic plugin should always be available: ${plugin.name}/${plugin.version}`, + ); + } + return pkg; } } @@ -279,7 +314,7 @@ class DynamicPluginsEnabledFeatureDiscoveryService ...this.dynamicPlugins .backendPlugins() .flatMap((plugin): BackendFeature[] => { - if (plugin.installer.kind === 'new') { + if (plugin.installer?.kind === 'new') { const installed = plugin.installer.install(); if (Array.isArray(installed)) { return installed; diff --git a/packages/backend-dynamic-feature-service/src/manager/types.ts b/packages/backend-dynamic-feature-service/src/manager/types.ts index a4f2937fd5..d3989b5ff9 100644 --- a/packages/backend-dynamic-feature-service/src/manager/types.ts +++ b/packages/backend-dynamic-feature-service/src/manager/types.ts @@ -43,6 +43,7 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { IndexBuilder } from '@backstage/plugin-search-backend-node'; import { EventsBackend } from '@backstage/plugin-events-backend'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { ScannedPluginPackage } from '../scanner'; /** * @public @@ -78,21 +79,22 @@ export type LegacyPluginEnvironment = { export interface DynamicPluginProvider extends FrontendPluginProvider, BackendPluginProvider { - plugins(): DynamicPlugin[]; + plugins(includeFailed?: boolean): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; } /** * @public */ export interface BackendPluginProvider { - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; } /** * @public */ export interface FrontendPluginProvider { - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; } /** @@ -103,6 +105,7 @@ export interface BaseDynamicPlugin { version: string; role: PackageRole; platform: PackagePlatform; + failure?: string; } /** @@ -122,7 +125,7 @@ export interface FrontendDynamicPlugin extends BaseDynamicPlugin { */ export interface BackendDynamicPlugin extends BaseDynamicPlugin { platform: 'node'; - installer: BackendDynamicPluginInstaller; + installer?: BackendDynamicPluginInstaller; } /** diff --git a/yarn.lock b/yarn.lock index f5184e9149..7285cc8a19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40001,6 +40001,7 @@ __metadata: resolution: "root@workspace:." dependencies: "@backstage/cli": "workspace:*" + "@backstage/cli-node": "workspace:^" "@backstage/codemods": "workspace:*" "@backstage/create-app": "workspace:*" "@backstage/e2e-test-utils": "workspace:*"