diff --git a/.changeset/twenty-carrots-jog.md b/.changeset/twenty-carrots-jog.md new file mode 100644 index 0000000000..f4b4db8fcb --- /dev/null +++ b/.changeset/twenty-carrots-jog.md @@ -0,0 +1,31 @@ +--- +'@backstage/backend-app-api': patch +--- + +Added a configuration to permit backend plugin failures on startup: + +```yaml +backend: + ... + startup: + plugins: + plugin-x: + onPluginBootFailure: continue +``` + +This configuration permits `plugin-x` to fail on startup. Omitting the +`onPluginBootFailure` configuration matches the previous behavior, wherein any +individual plugin failure aborts backend startup. + +The default can also be changed, so that continuing on failure is the default +unless otherwise specified: + +```yaml +backend: + startup: + default: + onPluginBootFailure: continue + plugins: + catalog: + onPluginBootFailure: abort +``` diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts index b5e56af67c..0cf91852a1 100644 --- a/packages/backend-app-api/config.d.ts +++ b/packages/backend-app-api/config.d.ts @@ -23,5 +23,31 @@ export interface Config { include?: string[]; exclude?: string[]; }; + + startup?: { + default?: { + /** + * The default value for `onPluginBootFailure` if not specified for a particular plugin. + * This defaults to 'abort', which means `onPluginBootFailure: continue` must be specified + * for backend startup to continue on plugin boot failure. This can also be set to + * 'continue', which flips the logic for individual plugins so that they must be set to + * `onPluginBootFailure: abort` to be required. + */ + onPluginBootFailure?: 'continue' | 'abort'; + }; + plugins?: { + [pluginId: string]: { + /** + * Used to control backend startup behavior when this plugin fails to boot up. Setting + * this to `continue` allows the backend to continue starting up, even if this plugin + * fails. This can enable leaving a crashing plugin installed, but still permit backend + * startup, which may help troubleshoot data-dependent issues. Plugin failures for plugins + * set to `abort` are fatal (this is the default unless overridden by the `default` + * setting). + */ + onPluginBootFailure?: 'continue' | 'abort'; + }; + }; + }; }; } diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 39a54747d5..fe0a4f526b 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -560,6 +560,95 @@ describe('BackendInitializer', () => { ); }); + it('should permit startup errors for plugins with onPluginBootFailure: continue', async () => { + const init = new BackendInitializer([ + mockServices.rootLifecycle.factory(), + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { plugins: { test: { onPluginBootFailure: 'continue' } } }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'test', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw new Error('NOPE'); + }, + }); + }, + }), + ); + await expect(init.start()).resolves.not.toThrow(); + }); + + it('should permit startup errors if the default onPluginBootFailure is continue', async () => { + const init = new BackendInitializer([ + mockServices.rootLifecycle.factory(), + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { default: { onPluginBootFailure: 'continue' } }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'test', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw new Error('NOPE'); + }, + }); + }, + }), + ); + await expect(init.start()).resolves.not.toThrow(); + }); + + it('should forward errors for plugins explicitly marked to abort when the default is continue', async () => { + const init = new BackendInitializer([ + mockServices.rootLifecycle.factory(), + mockServices.rootLogger.factory(), + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + default: { onPluginBootFailure: 'continue' }, + plugins: { test: { onPluginBootFailure: 'abort' } }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'test', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw new Error('NOPE'); + }, + }); + }, + }), + ); + await expect(init.start()).rejects.toThrow( + "Plugin 'test' startup failed; caused by Error: NOPE", + ); + }); + it('should forward errors when multiple plugins fail to start', async () => { const init = new BackendInitializer([]); init.add( diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 02fad4f217..f521966d4d 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -24,6 +24,7 @@ import { RootLifecycleService, createServiceFactory, } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; import { ServiceOrExtensionPoint } from './types'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -321,9 +322,19 @@ export class BackendInitializer { await this.#serviceRegistry.get(coreServices.rootLogger, 'root'), ); + const rootConfig = await this.#serviceRegistry.get( + coreServices.rootConfig, + 'root', + ); + // All plugins are initialized in parallel const results = await Promise.allSettled( allPluginIds.map(async pluginId => { + const isBootFailurePermitted = this.#getPluginBootFailurePredicate( + pluginId, + rootConfig, + ); + try { // Initialize all eager services await this.#serviceRegistry.initializeEagerServicesWithScope( @@ -392,8 +403,12 @@ export class BackendInitializer { await lifecycleService.startup(); } catch (error: unknown) { assertError(error); - initLogger.onPluginFailed(pluginId, error); - throw error; + if (isBootFailurePermitted) { + initLogger.onPermittedPluginFailure(pluginId, error); + } else { + initLogger.onPluginFailed(pluginId, error); + throw error; + } } }), ); @@ -620,6 +635,20 @@ export class BackendInitializer { } } } + + #getPluginBootFailurePredicate(pluginId: string, config?: Config): boolean { + const defaultStartupBootFailureValue = + config?.getOptionalString( + 'backend.startup.default.onPluginBootFailure', + ) ?? 'abort'; + + const pluginStartupBootFailureValue = + config?.getOptionalString( + `backend.startup.plugins.${pluginId}.onPluginBootFailure`, + ) ?? defaultStartupBootFailureValue; + + return pluginStartupBootFailureValue === 'continue'; + } } function toInternalBackendFeature( diff --git a/packages/backend-app-api/src/wiring/createInitializationLogger.ts b/packages/backend-app-api/src/wiring/createInitializationLogger.ts index e623d43074..0978cfb0c8 100644 --- a/packages/backend-app-api/src/wiring/createInitializationLogger.ts +++ b/packages/backend-app-api/src/wiring/createInitializationLogger.ts @@ -28,6 +28,7 @@ export function createInitializationLogger( ): { onPluginStarted(pluginId: string): void; onPluginFailed(pluginId: string, error: Error): void; + onPermittedPluginFailure(pluginId: string, error: Error): void; onAllStarted(): void; } { const logger = rootLogger?.child({ type: 'initialization' }); @@ -79,6 +80,13 @@ export function createInitializationLogger( error, ); }, + onPermittedPluginFailure(pluginId: string, error: Error) { + starting.delete(pluginId); + logger?.error( + `Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin so startup will continue.`, + error, + ); + }, onAllStarted() { logger?.info(`Plugin initialization complete${getInitStatus()}`);