From 595f99a1eabc0f3cf12029850e3e7848b143f2a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 12:24:53 +0100 Subject: [PATCH 1/9] backend-app-api: add createAllowBootFailurePredicate Signed-off-by: Patrik Oldsberg --- .../createAllowBootFailurePredicate.test.ts | 482 ++++++++++++++++++ .../wiring/createAllowBootFailurePredicate.ts | 92 ++++ 2 files changed, 574 insertions(+) create mode 100644 packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts create mode 100644 packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.ts diff --git a/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts new file mode 100644 index 0000000000..183d7a5363 --- /dev/null +++ b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts @@ -0,0 +1,482 @@ +/* + * 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. + */ + +import { ConfigReader } from '@backstage/config'; +import { createAllowBootFailurePredicate } from './createAllowBootFailurePredicate'; + +describe('createAllowBootFailurePredicate', () => { + describe('when no config is provided', () => { + it('should default to abort for plugins', () => { + const predicate = createAllowBootFailurePredicate(); + expect(predicate('test-plugin')).toBe(false); + }); + + it('should default to abort for modules', () => { + const predicate = createAllowBootFailurePredicate(); + expect(predicate('test-plugin', 'test-module')).toBe(false); + }); + }); + + describe('default plugin boot failure configuration', () => { + it('should use abort as default when not configured', () => { + const config = new ConfigReader({}); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(false); + }); + + it('should use continue when default is set to continue', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + }); + + it('should use abort when default is set to abort', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(false); + }); + }); + + describe('default module boot failure configuration', () => { + it('should use abort as default when not configured', () => { + const config = new ConfigReader({}); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(false); + }); + + it('should use continue when default is set to continue', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(true); + }); + + it('should use abort when default is set to abort', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(false); + }); + }); + + describe('plugin-specific overrides', () => { + it('should override default with plugin-specific continue', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + }); + + it('should override default with plugin-specific abort', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'abort', + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(false); + }); + + it('should use default when plugin-specific config is not set', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: { + 'other-plugin': { + onPluginBootFailure: 'abort', + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + }); + + it('should handle multiple plugins independently', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + }, + plugins: { + 'plugin-a': { + onPluginBootFailure: 'continue', + }, + 'plugin-b': { + onPluginBootFailure: 'abort', + }, + 'plugin-c': { + // No override, uses default + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('plugin-a')).toBe(true); + expect(predicate('plugin-b')).toBe(false); + expect(predicate('plugin-c')).toBe(false); + }); + }); + + describe('module-specific overrides', () => { + it('should override default with module-specific continue', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + modules: { + 'test-module': { + onPluginModuleBootFailure: 'continue', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(true); + }); + + it('should override default with module-specific abort', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: { + 'test-module': { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(false); + }); + + it('should use default when module-specific config is not set', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: { + 'other-module': { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(true); + }); + + it('should handle multiple modules independently', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + modules: { + 'module-a': { + onPluginModuleBootFailure: 'continue', + }, + 'module-b': { + onPluginModuleBootFailure: 'abort', + }, + 'module-c': { + // No override, uses default + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'module-a')).toBe(true); + expect(predicate('test-plugin', 'module-b')).toBe(false); + expect(predicate('test-plugin', 'module-c')).toBe(false); + }); + + it('should handle modules across different plugins', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'plugin-a': { + modules: { + 'module-x': { + onPluginModuleBootFailure: 'continue', + }, + }, + }, + 'plugin-b': { + modules: { + 'module-y': { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('plugin-a', 'module-x')).toBe(true); + expect(predicate('plugin-b', 'module-y')).toBe(false); + }); + }); + + describe('combined plugin and module configurations', () => { + it('should use module config when both plugin and module configs exist', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + modules: { + 'test-module': { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + expect(predicate('test-plugin', 'test-module')).toBe(false); + }); + + it('should use plugin default when module config does not exist', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + modules: { + 'other-module': { + onPluginModuleBootFailure: 'abort', + }, + }, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + expect(predicate('test-plugin', 'test-module')).toBe(false); // Uses default module config + }); + }); + + describe('edge cases', () => { + it('should handle empty plugins config', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: {}, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin')).toBe(true); + }); + + it('should handle plugin with empty modules config', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: {}, + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(true); + }); + + it('should handle plugin without modules config', () => { + const config = new ConfigReader({ + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'abort', + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('test-plugin', 'test-module')).toBe(true); + }); + + it('should handle case sensitivity correctly', () => { + const config = new ConfigReader({ + backend: { + startup: { + plugins: { + 'Test-Plugin': { + onPluginBootFailure: 'continue', + }, + }, + }, + }, + }); + const predicate = createAllowBootFailurePredicate(config); + expect(predicate('Test-Plugin')).toBe(true); + expect(predicate('test-plugin')).toBe(false); // Different case + }); + }); + + describe('performance - configuration read upfront', () => { + it('should read configuration only once', () => { + const configData = { + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: { + 'plugin-a': { + onPluginBootFailure: 'abort', + modules: { + 'module-x': { + onPluginModuleBootFailure: 'continue', + }, + }, + }, + }, + }, + }, + }; + const config = new ConfigReader(configData); + const predicate = createAllowBootFailurePredicate(config); + + // Call predicate multiple times - should use cached values + expect(predicate('plugin-a')).toBe(false); + expect(predicate('plugin-a')).toBe(false); + expect(predicate('plugin-a', 'module-x')).toBe(true); + expect(predicate('plugin-a', 'module-x')).toBe(true); + expect(predicate('plugin-b')).toBe(true); // Uses default + }); + }); +}); diff --git a/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.ts b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.ts new file mode 100644 index 0000000000..15b73cb8ed --- /dev/null +++ b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.ts @@ -0,0 +1,92 @@ +/* + * 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. + */ + +import { Config } from '@backstage/config'; + +export type AllowBootFailurePredicate = ( + pluginId: string, + moduleId?: string, +) => boolean; + +/** + * Creates a predicate function that determines whether a boot failure should be + * allowed for a given plugin or module based on configuration. + * + * @param config - The configuration object to read boot failure settings from + * @returns A predicate function that accepts a pluginId and optional moduleId, + * and returns true if boot failures should be allowed, false otherwise. + */ +export function createAllowBootFailurePredicate( + config?: Config, +): AllowBootFailurePredicate { + // Read default values upfront + const defaultPluginBootFailure = + config?.getOptionalString('backend.startup.default.onPluginBootFailure') ?? + 'abort'; + const defaultModuleBootFailure = + config?.getOptionalString( + 'backend.startup.default.onPluginModuleBootFailure', + ) ?? 'abort'; + + // Read plugin-specific overrides upfront + const pluginOverrides = new Map(); + const moduleOverrides = new Map>(); + + const pluginsConfig = config?.getOptionalConfig('backend.startup.plugins'); + if (pluginsConfig) { + for (const pluginId of pluginsConfig.keys()) { + const pluginConfig = pluginsConfig.getConfig(pluginId); + const pluginBootFailure = pluginConfig.getOptionalString( + 'onPluginBootFailure', + ); + if (pluginBootFailure) { + pluginOverrides.set(pluginId, pluginBootFailure); + } + + // Read module-specific overrides + const modulesConfig = pluginConfig.getOptionalConfig('modules'); + if (modulesConfig) { + const moduleMap = new Map(); + for (const moduleId of modulesConfig.keys()) { + const moduleConfig = modulesConfig.getConfig(moduleId); + const moduleBootFailure = moduleConfig.getOptionalString( + 'onPluginModuleBootFailure', + ); + if (moduleBootFailure) { + moduleMap.set(moduleId, moduleBootFailure); + } + } + if (moduleMap.size > 0) { + moduleOverrides.set(pluginId, moduleMap); + } + } + } + } + + return (pluginId: string, moduleId?: string): boolean => { + if (moduleId !== undefined) { + // Module-specific boot failure predicate + const moduleMap = moduleOverrides.get(pluginId); + const moduleBootFailure = + moduleMap?.get(moduleId) ?? defaultModuleBootFailure; + return moduleBootFailure === 'continue'; + } + // Plugin-specific boot failure predicate + const pluginBootFailure = + pluginOverrides.get(pluginId) ?? defaultPluginBootFailure; + return pluginBootFailure === 'continue'; + }; +} From d69df5a70edd58c5e960365ea8b2e89091d401a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 19:17:28 +0100 Subject: [PATCH 2/9] backend-app-api: refactor startup tracking and report results Signed-off-by: Patrik Oldsberg --- .../src/wiring/BackendInitializer.test.ts | 634 ++++++++++++++++-- .../src/wiring/BackendInitializer.ts | 138 ++-- .../src/wiring/BackendStartupError.ts | 71 ++ .../src/wiring/BackstageBackend.ts | 6 +- .../src/wiring/createInitializationLogger.ts | 125 ---- .../createInitializationResultCollector.ts | 178 +++++ packages/backend-app-api/src/wiring/index.ts | 8 +- packages/backend-app-api/src/wiring/types.ts | 87 ++- .../permissionsRegistryServiceFactory.test.ts | 14 +- 9 files changed, 985 insertions(+), 276 deletions(-) create mode 100644 packages/backend-app-api/src/wiring/BackendStartupError.ts delete mode 100644 packages/backend-app-api/src/wiring/createInitializationLogger.ts create mode 100644 packages/backend-app-api/src/wiring/createInitializationResultCollector.ts diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 965e467501..f5e4b80417 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -26,6 +26,7 @@ import { } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; import { mockServices } from '@backstage/backend-test-utils'; +import { BackendStartupError } from './BackendStartupError'; const baseFactories = [ mockServices.rootLifecycle.factory(), @@ -806,49 +807,6 @@ describe('BackendInitializer', () => { ); }); - it('should forward errors when multiple plugins fail to start', async () => { - const init = new BackendInitializer([]); - init.add( - createBackendPlugin({ - pluginId: 'test-1', - register(reg) { - reg.registerInit({ - deps: {}, - async init() { - throw new Error('NOPE A'); - }, - }); - }, - }), - ); - init.add( - createBackendPlugin({ - pluginId: 'test-2', - register(reg) { - reg.registerInit({ - deps: {}, - async init() { - throw new Error('NOPE B'); - }, - }); - }, - }), - ); - const result = init.start(); - - await expect(result).rejects.toThrow('Backend startup failed'); - await expect(result).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ - message: "Plugin 'test-1' startup failed; caused by Error: NOPE A", - }), - expect.objectContaining({ - message: "Plugin 'test-2' startup failed; caused by Error: NOPE B", - }), - ], - }); - }); - it('should forward errors when modules fail to start', async () => { const init = new BackendInitializer([]); init.add(testPlugin); @@ -1289,4 +1247,594 @@ describe('BackendInitializer', () => { await backend.add(slowConsumerModule); await backend.start(); }); + + describe('startup results', () => { + it('should return successful startup result when all plugins and modules start successfully', async () => { + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin2', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + }, + ], + }); + expect(result.plugins[0].failure).toBeUndefined(); + expect(result.plugins[0].modules[0].failure).toBeUndefined(); + expect(result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + modules: [ + { + moduleId: 'module2', + }, + ], + }); + expect(result.plugins[1].failure).toBeUndefined(); + expect(result.plugins[1].modules[0].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when plugin fails to start', async () => { + const init = new BackendInitializer(baseFactories); + const error = new Error('Plugin failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(2); + expect(err?.result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + failure: { error, allowed: false }, + }); + expect(err?.result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + }); + expect(err?.result.plugins[1].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when module fails to start', async () => { + const init = new BackendInitializer(baseFactories); + const error = new Error('Module failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(1); + expect(err?.result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + failure: { error, allowed: false }, + }, + ], + }); + expect(err?.result.plugins[0].failure).toBeUndefined(); + }); + + it('should throw BackendStartupError with results when multiple plugins fail', async () => { + const init = new BackendInitializer(baseFactories); + const error1 = new Error('Plugin1 failed'); + const error2 = new Error('Plugin2 failed'); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error1; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error2; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(2); + expect(err?.result.plugins[0].failure?.error).toBe(error1); + expect(err?.result.plugins[1].failure?.error).toBe(error2); + }); + + it('should return results with failure status when plugin boot failure is permitted', async () => { + const error = new Error('Plugin failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { plugin1: { onPluginBootFailure: 'continue' } }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendPlugin({ + pluginId: 'plugin2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(2); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + failure: { error, allowed: true }, + }); + expect(result.plugins[1]).toMatchObject({ + pluginId: 'plugin2', + }); + expect(result.plugins[1].failure).toBeUndefined(); + }); + + it('should return results with failure status when module boot failure is permitted', async () => { + const error = new Error('Module failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { + plugin1: { + modules: { + module1: { onPluginModuleBootFailure: 'continue' }, + }, + }, + }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [ + { + moduleId: 'module1', + failure: { error, allowed: true }, + }, + { + moduleId: 'module2', + }, + ], + }); + expect(result.plugins[0].failure).toBeUndefined(); + expect(result.plugins[0].modules[1].failure).toBeUndefined(); + }); + + it('should include all module results even when some modules fail', async () => { + const error1 = new Error('Module1 failed'); + const error2 = new Error('Module2 failed'); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { + plugin1: { + modules: { + module1: { onPluginModuleBootFailure: 'continue' }, + module2: { onPluginModuleBootFailure: 'continue' }, + }, + }, + }, + }, + }, + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error1; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module2', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error2; + }, + }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'plugin1', + moduleId: 'module3', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins[0].modules).toHaveLength(3); + expect(result.plugins[0].modules[0]).toMatchObject({ + moduleId: 'module1', + failure: { error: error1, allowed: true }, + }); + expect(result.plugins[0].modules[1]).toMatchObject({ + moduleId: 'module2', + failure: { error: error2, allowed: true }, + }); + expect(result.plugins[0].modules[2]).toMatchObject({ + moduleId: 'module3', + }); + expect(result.plugins[0].modules[2].failure).toBeUndefined(); + }); + + it('should return results for plugins without modules', async () => { + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toMatchObject({ + pluginId: 'plugin1', + modules: [], + }); + expect(result.plugins[0].failure).toBeUndefined(); + }); + + it('should include error information in BackendStartupError', async () => { + const error = new Error('Test error'); + const init = new BackendInitializer(baseFactories); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: {}, + async init() { + throw error; + }, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.message).toBe( + "Backend startup failed due to the following errors:\n Plugin 'plugin1' startup failed; caused by Error: Test error", + ); + expect(err?.result).toBeDefined(); + expect(err?.result.plugins[0].failure?.error).toBe(error); + }); + + it('should handle plugin scoped service factory failures', async () => { + const serviceFactoryError = new Error('Service factory failed'); + const ref = createServiceRef<{ value: string }>({ + id: 'failing-service', + }); + const init = new BackendInitializer([ + ...baseFactories, + createServiceFactory({ + service: ref, + deps: {}, + factory: () => { + throw serviceFactoryError; + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: { service: ref }, + async init() {}, + }); + }, + }), + ); + + const err = await init.start().then( + () => { + throw new Error('Expected BackendStartupError to be thrown'); + }, + (e: BackendStartupError) => e, + ); + + expect(err).toBeInstanceOf(BackendStartupError); + expect(err?.result.plugins).toHaveLength(1); + expect(err?.result.plugins[0].pluginId).toBe('plugin1'); + expect(err?.result.plugins[0].failure).toBeDefined(); + expect(err?.result.plugins[0].failure?.allowed).toBe(false); + expect(err?.result.plugins[0].failure?.error.message).toContain( + "Failed to instantiate service 'failing-service'", + ); + expect(err?.result.plugins[0].failure?.error.message).toContain( + 'Service factory failed', + ); + }); + + it('should handle plugin scoped service factory failures with allowed boot failure', async () => { + const serviceFactoryError = new Error('Service factory failed'); + const ref = createServiceRef<{ value: string }>({ + id: 'failing-service', + }); + const init = new BackendInitializer([ + ...baseFactories, + mockServices.rootConfig.factory({ + data: { + backend: { + startup: { + plugins: { plugin1: { onPluginBootFailure: 'continue' } }, + }, + }, + }, + }), + createServiceFactory({ + service: ref, + deps: {}, + factory: () => { + throw serviceFactoryError; + }, + }), + ]); + init.add( + createBackendPlugin({ + pluginId: 'plugin1', + register(reg) { + reg.registerInit({ + deps: { service: ref }, + async init() {}, + }); + }, + }), + ); + + const result = await init.start(); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].pluginId).toBe('plugin1'); + expect(result.plugins[0].failure).toBeDefined(); + expect(result.plugins[0].failure?.allowed).toBe(true); + expect(result.plugins[0].failure?.error.message).toContain( + "Failed to instantiate service 'failing-service'", + ); + expect(result.plugins[0].failure?.error.message).toContain( + 'Service factory failed', + ); + }); + }); }); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index e7480d3beb..83d5da72cd 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -24,7 +24,6 @@ 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 @@ -38,9 +37,12 @@ import type { InternalServiceFactory } from '../../../backend-plugin-api/src/ser import { ForwardedError, ConflictError, assertError } from '@backstage/errors'; import { DependencyGraph } from '../lib/DependencyGraph'; import { ServiceRegistry } from './ServiceRegistry'; -import { createInitializationLogger } from './createInitializationLogger'; +import { createInitializationResultCollector } from './createInitializationResultCollector'; import { deepFreeze, unwrapFeature } from './helpers'; import type { RootInstanceMetadataServicePluginInfo } from '@backstage/backend-plugin-api'; +import { BackendStartupResult } from './types'; +import { BackendStartupError } from './BackendStartupError'; +import { createAllowBootFailurePredicate } from './createAllowBootFailurePredicate'; export interface BackendRegisterInit { consumes: Set; @@ -148,7 +150,7 @@ function createRootInstanceMetadataServiceFactory( } export class BackendInitializer { - #startPromise?: Promise; + #startPromise?: Promise; #stopPromise?: Promise; #registrations = new Array(); #extensionPoints = new Map(); @@ -224,7 +226,7 @@ export class BackendInitializer { } } - async start(): Promise { + async start(): Promise { if (this.#startPromise) { throw new Error('Backend has already started'); } @@ -235,10 +237,10 @@ export class BackendInitializer { instanceRegistry.register(this); this.#startPromise = this.#doStart(); - await this.#startPromise; + return await this.#startPromise; } - async #doStart(): Promise { + async #doStart(): Promise { this.#serviceRegistry.checkForCircularDeps(); for (const feature of this.#registeredFeatures) { @@ -332,26 +334,26 @@ export class BackendInitializer { } } - const allPluginIds = [...pluginInits.keys()]; - - const initLogger = createInitializationLogger( - allPluginIds, - await this.#serviceRegistry.get(coreServices.rootLogger, 'root'), - ); + const pluginIds = [...pluginInits.keys()]; const rootConfig = await this.#serviceRegistry.get( coreServices.rootConfig, 'root', ); + const rootLogger = await this.#serviceRegistry.get( + coreServices.rootLogger, + 'root', + ); + + const resultCollector = createInitializationResultCollector({ + pluginIds, + logger: rootLogger, + allowBootFailurePredicate: createAllowBootFailurePredicate(rootConfig), + }); // All plugins are initialized in parallel - const results = await Promise.allSettled( - allPluginIds.map(async pluginId => { - const isBootFailurePermitted = this.#getPluginBootFailurePredicate( - pluginId, - rootConfig, - ); - + await Promise.all( + pluginIds.map(async pluginId => { try { // Initialize all eager services await this.#serviceRegistry.initializeEagerServicesWithScope( @@ -382,37 +384,21 @@ export class BackendInitializer { } await tree.parallelTopologicalTraversal( async ({ moduleId, moduleInit }) => { - const isModuleBootFailurePermitted = - this.#getPluginModuleBootFailurePredicate( - pluginId, - moduleId, - rootConfig, - ); - try { const moduleDeps = await this.#getInitDeps( moduleInit.init.deps, pluginId, moduleId, ); - await moduleInit.init.func(moduleDeps).catch(error => { - throw new ForwardedError( - `Module '${moduleId}' for plugin '${pluginId}' startup failed`, - error, - ); - }); + await moduleInit.init.func(moduleDeps); + resultCollector.onPluginModuleResult(pluginId, moduleId); } catch (error: unknown) { assertError(error); - if (isModuleBootFailurePermitted) { - initLogger.onPermittedPluginModuleFailure( - pluginId, - moduleId, - error, - ); - } else { - initLogger.onPluginModuleFailed(pluginId, moduleId, error); - throw error; - } + resultCollector.onPluginModuleResult( + pluginId, + moduleId, + error, + ); } }, ); @@ -426,46 +412,36 @@ export class BackendInitializer { pluginInit.init.deps, pluginId, ); - await pluginInit.init.func(pluginDeps).catch(error => { - throw new ForwardedError( - `Plugin '${pluginId}' startup failed`, - error, - ); - }); + await pluginInit.init.func(pluginDeps); } - initLogger.onPluginStarted(pluginId); + resultCollector.onPluginResult(pluginId); // Once the plugin and all modules have been initialized, we can signal that the plugin has stared up successfully const lifecycleService = await this.#getPluginLifecycleImpl(pluginId); await lifecycleService.startup(); } catch (error: unknown) { assertError(error); - if (isBootFailurePermitted) { - initLogger.onPermittedPluginFailure(pluginId, error); - } else { - initLogger.onPluginFailed(pluginId, error); - throw error; - } + resultCollector.onPluginResult(pluginId, error); } }), - ); + ).catch(error => { + throw new ForwardedError( + 'Unexpected uncaught backend startup error', + error, + ); + }); - const initErrors = results.flatMap(r => - r.status === 'rejected' ? [r.reason] : [], - ); - if (initErrors.length === 1) { - throw initErrors[0]; - } else if (initErrors.length > 1) { - // TODO(Rugvip): Seems like there aren't proper types for AggregateError yet - throw new (AggregateError as any)(initErrors, 'Backend startup failed'); + const startupResult = resultCollector.finalize(); + if (startupResult.result === 'failure') { + throw new BackendStartupError(startupResult); } // Once all plugins and modules have been initialized, we can signal that the backend has started up successfully const lifecycleService = await this.#getRootLifecycleImpl(); await lifecycleService.startup(); - initLogger.onAllStarted(); + return startupResult; } // It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit @@ -654,38 +630,6 @@ 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'; - } - - #getPluginModuleBootFailurePredicate( - pluginId: string, - moduleId: string, - config?: Config, - ): boolean { - const defaultStartupBootFailureValue = - config?.getOptionalString( - 'backend.startup.default.onPluginModuleBootFailure', - ) ?? 'abort'; - - const pluginModuleStartupBootFailureValue = - config?.getOptionalString( - `backend.startup.plugins.${pluginId}.modules.${moduleId}.onPluginModuleBootFailure`, - ) ?? defaultStartupBootFailureValue; - - return pluginModuleStartupBootFailureValue === 'continue'; - } } function toInternalBackendFeature( diff --git a/packages/backend-app-api/src/wiring/BackendStartupError.ts b/packages/backend-app-api/src/wiring/BackendStartupError.ts new file mode 100644 index 0000000000..7876b2c236 --- /dev/null +++ b/packages/backend-app-api/src/wiring/BackendStartupError.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2022 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 { CustomErrorBase } from '@backstage/errors'; +import { BackendStartupResult } from './types'; + +function formatMessage(startupResult: BackendStartupResult): string { + const parts: string[] = [ + 'Backend startup failed due to the following errors:', + ]; + const failures: string[] = []; + + for (const plugin of startupResult.plugins) { + if (plugin.failure && !plugin.failure.allowed) { + failures.push( + ` Plugin '${plugin.pluginId}' startup failed; caused by ${plugin.failure.error}`, + ); + } + + for (const mod of plugin.modules) { + if (mod.failure && !mod.failure.allowed) { + failures.push( + ` Module '${mod.moduleId}' for plugin '${plugin.pluginId}' startup failed; caused by ${mod.failure.error}`, + ); + } + } + } + + if (failures.length > 0) { + parts.push(...failures); + } + + return parts.join('\n'); +} + +/** + * Error thrown when backend startup fails. + * Includes detailed startup results for all plugins and modules. + * + * @public + */ +export class BackendStartupError extends CustomErrorBase { + name = 'BackendStartupError' as const; + + /** + * The startup results for all plugins and modules. + */ + readonly #startupResult: BackendStartupResult; + + constructor(startupResult: BackendStartupResult) { + super(formatMessage(startupResult)); + this.#startupResult = startupResult; + } + + get result(): BackendStartupResult { + return this.#startupResult; + } +} diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts index 49a8bbbbfb..a101a03375 100644 --- a/packages/backend-app-api/src/wiring/BackstageBackend.ts +++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts @@ -17,7 +17,7 @@ import { BackendFeature, ServiceFactory } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; import { unwrapFeature } from './helpers'; -import { Backend } from './types'; +import { Backend, BackendStartupResult } from './types'; export class BackstageBackend implements Backend { #initializer: BackendInitializer; @@ -34,8 +34,8 @@ export class BackstageBackend implements Backend { } } - async start(): Promise { - await this.#initializer.start(); + async start(): Promise { + return await this.#initializer.start(); } async stop(): Promise { diff --git a/packages/backend-app-api/src/wiring/createInitializationLogger.ts b/packages/backend-app-api/src/wiring/createInitializationLogger.ts deleted file mode 100644 index 600826a435..0000000000 --- a/packages/backend-app-api/src/wiring/createInitializationLogger.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * 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. - */ - -import { RootLoggerService } from '@backstage/backend-plugin-api'; - -const LOGGER_INTERVAL_MAX = 60_000; - -function joinIds(ids: Iterable): string { - return [...ids].map(id => `'${id}'`).join(', '); -} - -export function createInitializationLogger( - pluginIds: string[], - rootLogger?: RootLoggerService, -): { - onPluginStarted(pluginId: string): void; - onPluginFailed(pluginId: string, error: Error): void; - onPermittedPluginFailure(pluginId: string, error: Error): void; - onPluginModuleFailed(pluginId: string, moduleId: string, error: Error): void; - onPermittedPluginModuleFailure( - pluginId: string, - moduleId: string, - error: Error, - ): void; - onAllStarted(): void; -} { - const logger = rootLogger?.child({ type: 'initialization' }); - const starting = new Set(pluginIds); - const started = new Set(); - - logger?.info(`Plugin initialization started: ${joinIds(pluginIds)}`); - - const getInitStatus = () => { - let status = ''; - if (started.size > 0) { - status = `, newly initialized: ${joinIds(started)}`; - started.clear(); - } - if (starting.size > 0) { - status += `, still initializing: ${joinIds(starting)}`; - } - return status; - }; - - // Periodically log the initialization status with a fibonacci backoff - let interval = 1000; - let prevInterval = 0; - let timeout: NodeJS.Timeout | undefined; - const onTimeout = () => { - logger?.info(`Plugin initialization in progress${getInitStatus()}`); - - const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX); - prevInterval = interval; - interval = nextInterval; - - timeout = setTimeout(onTimeout, nextInterval); - }; - timeout = setTimeout(onTimeout, interval); - - return { - onPluginStarted(pluginId: string) { - starting.delete(pluginId); - started.add(pluginId); - }, - onPluginFailed(pluginId: string, error: Error) { - starting.delete(pluginId); - const status = - starting.size > 0 - ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` - : ''; - logger?.error( - `Plugin '${pluginId}' threw an error during startup${status}.`, - 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, - ); - }, - onPluginModuleFailed(pluginId: string, moduleId: string, error: Error) { - const status = - starting.size > 0 - ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` - : ''; - logger?.error( - `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup${status}.`, - error, - ); - }, - onPermittedPluginModuleFailure( - pluginId: string, - moduleId: string, - error: Error, - ) { - logger?.error( - `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin module so startup will continue.`, - error, - ); - }, - onAllStarted() { - logger?.info(`Plugin initialization complete${getInitStatus()}`); - - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - }, - }; -} diff --git a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts new file mode 100644 index 0000000000..4dca92ef58 --- /dev/null +++ b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts @@ -0,0 +1,178 @@ +/* + * 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. + */ + +import { RootLoggerService } from '@backstage/backend-plugin-api'; +import { AllowBootFailurePredicate } from './createAllowBootFailurePredicate'; +import { + BackendStartupResult, + PluginStartupResult, + ModuleStartupResult, +} from './types'; + +const LOGGER_INTERVAL_MAX = 60_000; + +function joinIds(ids: Iterable): string { + return [...ids].map(id => `'${id}'`).join(', '); +} + +export function createInitializationResultCollector(options: { + pluginIds: string[]; + logger?: RootLoggerService; + allowBootFailurePredicate: AllowBootFailurePredicate; +}): { + onPluginResult(pluginId: string, error?: Error): void; + onPluginModuleResult(pluginId: string, moduleId: string, error?: Error): void; + finalize(): BackendStartupResult; +} { + const logger = options.logger?.child({ type: 'initialization' }); + const beginAt = new Date(); + const starting = new Set(options.pluginIds); + const started = new Set(); + + let hasDisallowedFailures = false; + + const pluginResults: PluginStartupResult[] = []; + const moduleResultsByPlugin: Map = new Map( + Array.from(starting).map(pluginId => [pluginId, []]), + ); + + logger?.info(`Plugin initialization started: ${joinIds(starting)}`); + + const getInitStatus = () => { + let status = ''; + if (started.size > 0) { + status = `, newly initialized: ${joinIds(started)}`; + started.clear(); + } + if (starting.size > 0) { + status += `, still initializing: ${joinIds(starting)}`; + } + return status; + }; + + // Periodically log the initialization status with a fibonacci backoff + let interval = 1000; + let prevInterval = 0; + let timeout: NodeJS.Timeout | undefined; + const onTimeout = () => { + logger?.info(`Plugin initialization in progress${getInitStatus()}`); + + const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX); + prevInterval = interval; + interval = nextInterval; + + timeout = setTimeout(onTimeout, nextInterval); + }; + timeout = setTimeout(onTimeout, interval); + + return { + onPluginResult(pluginId: string, error?: Error) { + starting.delete(pluginId); + started.add(pluginId); + + const modules = moduleResultsByPlugin.get(pluginId); + if (!modules) { + throw new Error( + `Failed to push plugin result for nonexistent plugin '${pluginId}'`, + ); + } + + if (!error) { + pluginResults.push({ + pluginId, + resultAt: new Date(), + modules, + }); + } else { + const allowed = options.allowBootFailurePredicate(pluginId); + pluginResults.push({ + pluginId, + resultAt: new Date(), + modules, + failure: { + error, + allowed, + }, + }); + if (allowed) { + logger?.error( + `Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin so startup will continue.`, + error, + ); + } else { + hasDisallowedFailures = true; + const status = + starting.size > 0 + ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` + : ''; + logger?.error( + `Plugin '${pluginId}' threw an error during startup${status}.`, + error, + ); + } + } + }, + onPluginModuleResult(pluginId: string, moduleId: string, error?: Error) { + const moduleResults = moduleResultsByPlugin.get(pluginId); + if (!moduleResults) { + throw new Error( + `Failed to push module result for nonexistent plugin '${pluginId}'`, + ); + } + + if (!error) { + moduleResults.push({ moduleId, resultAt: new Date() }); + } else { + const allowed = options.allowBootFailurePredicate(pluginId, moduleId); + moduleResults.push({ + moduleId, + resultAt: new Date(), + failure: { error, allowed }, + }); + if (allowed) { + logger?.error( + `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup, but boot failure is permitted for this plugin module so startup will continue.`, + error, + ); + } else { + hasDisallowedFailures = true; + const status = + starting.size > 0 + ? `, waiting for ${starting.size} other plugins to finish before shutting down the process` + : ''; + logger?.error( + `Module ${moduleId} in Plugin '${pluginId}' threw an error during startup${status}.`, + error, + ); + } + } + }, + finalize() { + logger?.info(`Plugin initialization complete${getInitStatus()}`); + + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + return { + beginAt, + resultAt: new Date(), + result: hasDisallowedFailures ? 'failure' : 'success', + plugins: pluginResults, + }; + }, + }; +} diff --git a/packages/backend-app-api/src/wiring/index.ts b/packages/backend-app-api/src/wiring/index.ts index 00e4a9d4b4..fa021a61a6 100644 --- a/packages/backend-app-api/src/wiring/index.ts +++ b/packages/backend-app-api/src/wiring/index.ts @@ -14,5 +14,11 @@ * limitations under the License. */ -export type { Backend, CreateSpecializedBackendOptions } from './types'; +export type { + Backend, + CreateSpecializedBackendOptions, + BackendStartupResult, + PluginStartupResult, + ModuleStartupResult, +} from './types'; export { createSpecializedBackend } from './createSpecializedBackend'; diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index bd761ff3c8..fcb092a8c9 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -26,7 +26,7 @@ import { */ export interface Backend { add(feature: BackendFeature | Promise<{ default: BackendFeature }>): void; - start(): Promise; + start(): Promise; stop(): Promise; } @@ -43,3 +43,88 @@ export interface CreateSpecializedBackendOptions { export type ServiceOrExtensionPoint = | ExtensionPoint | ServiceRef; + +/** + * Result of a module startup attempt. + * @public + */ +export interface ModuleStartupResult { + /** + * The time the module startup was completed. + */ + resultAt: Date; + + /** + * The ID of the module. + */ + moduleId: string; + + /** + * If the startup failed, this contains information about the failure. + */ + failure?: { + /** + * The error that occurred during startup, if any. + */ + error: Error; + /** + * Whether the failure was allowed. + */ + allowed: boolean; + }; +} + +/** + * Result of a plugin startup attempt. + * @public + */ +export interface PluginStartupResult { + /** + * The time the plugin startup was completed. + */ + resultAt: Date; + /** + * If the startup failed, this contains information about the failure. + */ + failure?: { + /** + * The error that occurred during startup, if any. + */ + error: Error; + /** + * Whether the failure was allowed. + */ + allowed: boolean; + }; + /** + * The ID of the plugin. + */ + pluginId: string; + /** + * Results for all modules belonging to this plugin. + */ + modules: ModuleStartupResult[]; +} + +/** + * Result of a backend startup attempt. + * @public + */ +export interface BackendStartupResult { + /** + * The time the backend startup started. + */ + beginAt: Date; + /** + * The time the backend startup was completed. + */ + resultAt: Date; + /** + * Results for all plugins that were attempted to start. + */ + plugins: PluginStartupResult[]; + /** + * The result of the backend startup. + */ + result: 'success' | 'failure'; +} diff --git a/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts index 578c76378a..083207d6aa 100644 --- a/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.test.ts @@ -49,9 +49,10 @@ describe('permissionsRegistryServiceFactory', () => { }), ], }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'"`, - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Backend startup failed due to the following errors: + Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'" + `); await expect( startTestBackend({ @@ -75,8 +76,9 @@ describe('permissionsRegistryServiceFactory', () => { }), ], }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'"`, - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Backend startup failed due to the following errors: + Plugin 'test' startup failed; caused by Error: Resource type 'some-resource' belongs to plugin 'other', but was used with plugin 'test'" + `); }); }); From c38c640001854bc495bfea2f6f3702cfd7ab2f1f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 19:44:05 +0100 Subject: [PATCH 3/9] backend-app-api: update API report Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/report.api.md | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/backend-app-api/report.api.md b/packages/backend-app-api/report.api.md index 88fa34e6e4..319c35621f 100644 --- a/packages/backend-app-api/report.api.md +++ b/packages/backend-app-api/report.api.md @@ -17,11 +17,19 @@ export interface Backend { }>, ): void; // (undocumented) - start(): Promise; + start(): Promise; // (undocumented) stop(): Promise; } +// @public +export interface BackendStartupResult { + beginAt: Date; + plugins: PluginStartupResult[]; + result: 'success' | 'failure'; + resultAt: Date; +} + // @public (undocumented) export function createSpecializedBackend( options: CreateSpecializedBackendOptions, @@ -32,4 +40,25 @@ export interface CreateSpecializedBackendOptions { // (undocumented) defaultServiceFactories: ServiceFactory[]; } + +// @public +export interface ModuleStartupResult { + failure?: { + error: Error; + allowed: boolean; + }; + moduleId: string; + resultAt: Date; +} + +// @public +export interface PluginStartupResult { + failure?: { + error: Error; + allowed: boolean; + }; + modules: ModuleStartupResult[]; + pluginId: string; + resultAt: Date; +} ``` From 3f4b717b3f75c16e07a36c7990f1be2ee5c54c32 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 20:12:21 +0100 Subject: [PATCH 4/9] backend-app-api: export BackendStartupError Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/report.api.md | 10 ++++++++++ packages/backend-app-api/src/wiring/index.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/backend-app-api/report.api.md b/packages/backend-app-api/report.api.md index 319c35621f..78f4e1abde 100644 --- a/packages/backend-app-api/report.api.md +++ b/packages/backend-app-api/report.api.md @@ -4,6 +4,7 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CustomErrorBase } from '@backstage/errors'; import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public (undocumented) @@ -22,6 +23,15 @@ export interface Backend { stop(): Promise; } +// @public +export class BackendStartupError extends CustomErrorBase { + constructor(startupResult: BackendStartupResult); + // (undocumented) + name: 'BackendStartupError'; + // (undocumented) + get result(): BackendStartupResult; +} + // @public export interface BackendStartupResult { beginAt: Date; diff --git a/packages/backend-app-api/src/wiring/index.ts b/packages/backend-app-api/src/wiring/index.ts index fa021a61a6..cc084e5f34 100644 --- a/packages/backend-app-api/src/wiring/index.ts +++ b/packages/backend-app-api/src/wiring/index.ts @@ -22,3 +22,4 @@ export type { ModuleStartupResult, } from './types'; export { createSpecializedBackend } from './createSpecializedBackend'; +export { BackendStartupError } from './BackendStartupError'; From 5a2d53889b34161d94e8483b2d5eab1e42f62645 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 20:12:58 +0100 Subject: [PATCH 5/9] changesets: add changeset for backend.start() result Signed-off-by: Patrik Oldsberg --- .changeset/fine-eagles-sleep.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fine-eagles-sleep.md diff --git a/.changeset/fine-eagles-sleep.md b/.changeset/fine-eagles-sleep.md new file mode 100644 index 0000000000..0af6d0dfc0 --- /dev/null +++ b/.changeset/fine-eagles-sleep.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-app-api': minor +--- + +Introduced backend startup result tracking and error handling. The `Backend.start()` method now returns a `BackendStartupResult` with detailed success/failure status and timing information for all plugins and modules. When startup fails, a `BackendStartupError` is thrown that includes the complete startup results, making it easier to diagnose which plugins or modules failed. + +This also improves the default error message when backend startup fails, and of course makes it possible to craft your own custom error reporting based on the startup results. From c3273dacd35f578c88a15351b270a8dc9812d0db Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 20:16:38 +0100 Subject: [PATCH 6/9] docs/backend-system: document backend startup result Signed-off-by: Patrik Oldsberg --- .../architecture/02-backends.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/backend-system/architecture/02-backends.md b/docs/backend-system/architecture/02-backends.md index 8f903eb7a8..c1cb210f1a 100644 --- a/docs/backend-system/architecture/02-backends.md +++ b/docs/backend-system/architecture/02-backends.md @@ -38,3 +38,28 @@ Once you have added all desired features we call the `.start()` method. This cau Underneath the hood, `createBackend` calls `createSpecializedBackend` from `@backstage/backend-app-api` which is responsible for actually creating the backend instance, without any services or features. You can think of `createBackend` more of a 'batteries included' approach, while `createSpecializedBackend` is more low level. As mentioned previously there's also the ability to create multiple of these backends in your project so that you can split apart your backend and deploy different backends that can scale independently of each other. For instance you might choose to deploy a backend with only the catalog plugin enabled, and one with just the scaffolder plugin enabled. + +### Backend Startup Result + +The `Backend.start()` method returns a `BackendStartupResult` with detailed success/failure status and timing information for all plugins and modules. When startup fails, a `BackendStartupError` is thrown that includes the complete startup results, making it easier to diagnose which plugins or modules failed. + +```ts +backend.start( + result => { + console.log(`Backend startup result: ${JSON.stringify(result, null, 2)}`); + }, + error => { + if (error instanceof BackendStartupError) { + console.error( + `Backend startup failed: ${JSON.stringify(error.result, null, 2)}`, + ); + } else { + console.error( + `Unexpected error during backend startup: ${error.message}`, + ); + } + }, +); +``` + +This information is mostly useful if you want to add additional monitoring or debugging tools to your backend. The information is a structured representation of what is already logged during startup. From 36f98f12d843b16564db6f9b72c401f086da53e2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 18 Nov 2025 20:31:24 +0100 Subject: [PATCH 7/9] backend-app-api: wrap returned result Signed-off-by: Patrik Oldsberg --- docs/backend-system/architecture/02-backends.md | 2 +- packages/backend-app-api/report.api.md | 6 ++++-- .../src/wiring/BackendInitializer.test.ts | 12 ++++++------ .../src/wiring/BackendInitializer.ts | 14 +++++++------- .../backend-app-api/src/wiring/BackstageBackend.ts | 2 +- .../wiring/createInitializationResultCollector.ts | 2 +- packages/backend-app-api/src/wiring/types.ts | 6 +++--- 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/backend-system/architecture/02-backends.md b/docs/backend-system/architecture/02-backends.md index c1cb210f1a..4c9857df86 100644 --- a/docs/backend-system/architecture/02-backends.md +++ b/docs/backend-system/architecture/02-backends.md @@ -45,7 +45,7 @@ The `Backend.start()` method returns a `BackendStartupResult` with detailed succ ```ts backend.start( - result => { + ({ result }) => { console.log(`Backend startup result: ${JSON.stringify(result, null, 2)}`); }, error => { diff --git a/packages/backend-app-api/report.api.md b/packages/backend-app-api/report.api.md index 78f4e1abde..62b46b93e6 100644 --- a/packages/backend-app-api/report.api.md +++ b/packages/backend-app-api/report.api.md @@ -18,7 +18,9 @@ export interface Backend { }>, ): void; // (undocumented) - start(): Promise; + start(): Promise<{ + result: BackendStartupResult; + }>; // (undocumented) stop(): Promise; } @@ -35,8 +37,8 @@ export class BackendStartupError extends CustomErrorBase { // @public export interface BackendStartupResult { beginAt: Date; + outcome: 'success' | 'failure'; plugins: PluginStartupResult[]; - result: 'success' | 'failure'; resultAt: Date; } diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index f5e4b80417..1c0bf5a0ef 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -1298,7 +1298,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins).toHaveLength(2); expect(result.plugins[0]).toMatchObject({ @@ -1503,7 +1503,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins).toHaveLength(2); expect(result.plugins[0]).toMatchObject({ @@ -1574,7 +1574,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins).toHaveLength(1); expect(result.plugins[0]).toMatchObject({ @@ -1667,7 +1667,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins[0].modules).toHaveLength(3); expect(result.plugins[0].modules[0]).toMatchObject({ @@ -1698,7 +1698,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins).toHaveLength(1); expect(result.plugins[0]).toMatchObject({ @@ -1823,7 +1823,7 @@ describe('BackendInitializer', () => { }), ); - const result = await init.start(); + const { result } = await init.start(); expect(result.plugins).toHaveLength(1); expect(result.plugins[0].pluginId).toBe('plugin1'); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 83d5da72cd..c86fb0b29c 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -150,7 +150,7 @@ function createRootInstanceMetadataServiceFactory( } export class BackendInitializer { - #startPromise?: Promise; + #startPromise?: Promise<{ result: BackendStartupResult }>; #stopPromise?: Promise; #registrations = new Array(); #extensionPoints = new Map(); @@ -226,7 +226,7 @@ export class BackendInitializer { } } - async start(): Promise { + async start(): Promise<{ result: BackendStartupResult }> { if (this.#startPromise) { throw new Error('Backend has already started'); } @@ -240,7 +240,7 @@ export class BackendInitializer { return await this.#startPromise; } - async #doStart(): Promise { + async #doStart(): Promise<{ result: BackendStartupResult }> { this.#serviceRegistry.checkForCircularDeps(); for (const feature of this.#registeredFeatures) { @@ -432,16 +432,16 @@ export class BackendInitializer { ); }); - const startupResult = resultCollector.finalize(); - if (startupResult.result === 'failure') { - throw new BackendStartupError(startupResult); + const result = resultCollector.finalize(); + if (result.outcome === 'failure') { + throw new BackendStartupError(result); } // Once all plugins and modules have been initialized, we can signal that the backend has started up successfully const lifecycleService = await this.#getRootLifecycleImpl(); await lifecycleService.startup(); - return startupResult; + return { result }; } // It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts index a101a03375..79ee55780f 100644 --- a/packages/backend-app-api/src/wiring/BackstageBackend.ts +++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts @@ -34,7 +34,7 @@ export class BackstageBackend implements Backend { } } - async start(): Promise { + async start(): Promise<{ result: BackendStartupResult }> { return await this.#initializer.start(); } diff --git a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts index 4dca92ef58..a3faa455ff 100644 --- a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts +++ b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts @@ -170,7 +170,7 @@ export function createInitializationResultCollector(options: { return { beginAt, resultAt: new Date(), - result: hasDisallowedFailures ? 'failure' : 'success', + outcome: hasDisallowedFailures ? 'failure' : 'success', plugins: pluginResults, }; }, diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index fcb092a8c9..5782021656 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -26,7 +26,7 @@ import { */ export interface Backend { add(feature: BackendFeature | Promise<{ default: BackendFeature }>): void; - start(): Promise; + start(): Promise<{ result: BackendStartupResult }>; stop(): Promise; } @@ -124,7 +124,7 @@ export interface BackendStartupResult { */ plugins: PluginStartupResult[]; /** - * The result of the backend startup. + * The outcome of the backend startup. */ - result: 'success' | 'failure'; + outcome: 'success' | 'failure'; } From b595d941e2e05cbfb5d9f59969c78daac6530a47 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 21 Nov 2025 18:06:42 +0100 Subject: [PATCH 8/9] backend-app-api: switch test to use mockServices.rootConfig Signed-off-by: Patrik Oldsberg --- .../createAllowBootFailurePredicate.test.ts | 410 ++++++++++-------- 1 file changed, 224 insertions(+), 186 deletions(-) diff --git a/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts index 183d7a5363..f12998aa1d 100644 --- a/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts +++ b/packages/backend-app-api/src/wiring/createAllowBootFailurePredicate.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { createAllowBootFailurePredicate } from './createAllowBootFailurePredicate'; describe('createAllowBootFailurePredicate', () => { @@ -32,17 +32,19 @@ describe('createAllowBootFailurePredicate', () => { describe('default plugin boot failure configuration', () => { it('should use abort as default when not configured', () => { - const config = new ConfigReader({}); + const config = mockServices.rootConfig(); const predicate = createAllowBootFailurePredicate(config); expect(predicate('test-plugin')).toBe(false); }); it('should use continue when default is set to continue', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, }, }, }, @@ -52,11 +54,13 @@ describe('createAllowBootFailurePredicate', () => { }); it('should use abort when default is set to abort', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + }, }, }, }, @@ -68,17 +72,19 @@ describe('createAllowBootFailurePredicate', () => { describe('default module boot failure configuration', () => { it('should use abort as default when not configured', () => { - const config = new ConfigReader({}); + const config = mockServices.rootConfig(); const predicate = createAllowBootFailurePredicate(config); expect(predicate('test-plugin', 'test-module')).toBe(false); }); it('should use continue when default is set to continue', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, }, }, }, @@ -88,11 +94,13 @@ describe('createAllowBootFailurePredicate', () => { }); it('should use abort when default is set to abort', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -104,15 +112,17 @@ describe('createAllowBootFailurePredicate', () => { describe('plugin-specific overrides', () => { it('should override default with plugin-specific continue', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'abort', - }, - plugins: { - 'test-plugin': { - onPluginBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + }, }, }, }, @@ -123,15 +133,17 @@ describe('createAllowBootFailurePredicate', () => { }); it('should override default with plugin-specific abort', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'continue', - }, - plugins: { - 'test-plugin': { - onPluginBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'abort', + }, }, }, }, @@ -142,15 +154,17 @@ describe('createAllowBootFailurePredicate', () => { }); it('should use default when plugin-specific config is not set', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'continue', - }, - plugins: { - 'other-plugin': { - onPluginBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: { + 'other-plugin': { + onPluginBootFailure: 'abort', + }, }, }, }, @@ -161,21 +175,23 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle multiple plugins independently', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'abort', - }, - plugins: { - 'plugin-a': { - onPluginBootFailure: 'continue', - }, - 'plugin-b': { + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { onPluginBootFailure: 'abort', }, - 'plugin-c': { - // No override, uses default + plugins: { + 'plugin-a': { + onPluginBootFailure: 'continue', + }, + 'plugin-b': { + onPluginBootFailure: 'abort', + }, + 'plugin-c': { + // No override, uses default + }, }, }, }, @@ -190,17 +206,19 @@ describe('createAllowBootFailurePredicate', () => { describe('module-specific overrides', () => { it('should override default with module-specific continue', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'abort', - }, - plugins: { - 'test-plugin': { - modules: { - 'test-module': { - onPluginModuleBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + modules: { + 'test-module': { + onPluginModuleBootFailure: 'continue', + }, }, }, }, @@ -213,17 +231,19 @@ describe('createAllowBootFailurePredicate', () => { }); it('should override default with module-specific abort', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'continue', - }, - plugins: { - 'test-plugin': { - modules: { - 'test-module': { - onPluginModuleBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: { + 'test-module': { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -236,17 +256,19 @@ describe('createAllowBootFailurePredicate', () => { }); it('should use default when module-specific config is not set', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'continue', - }, - plugins: { - 'test-plugin': { - modules: { - 'other-module': { - onPluginModuleBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: { + 'other-module': { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -259,23 +281,25 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle multiple modules independently', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'abort', - }, - plugins: { - 'test-plugin': { - modules: { - 'module-a': { - onPluginModuleBootFailure: 'continue', - }, - 'module-b': { - onPluginModuleBootFailure: 'abort', - }, - 'module-c': { - // No override, uses default + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + modules: { + 'module-a': { + onPluginModuleBootFailure: 'continue', + }, + 'module-b': { + onPluginModuleBootFailure: 'abort', + }, + 'module-c': { + // No override, uses default + }, }, }, }, @@ -290,24 +314,26 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle modules across different plugins', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'abort', - }, - plugins: { - 'plugin-a': { - modules: { - 'module-x': { - onPluginModuleBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'plugin-a': { + modules: { + 'module-x': { + onPluginModuleBootFailure: 'continue', + }, }, }, - }, - 'plugin-b': { - modules: { - 'module-y': { - onPluginModuleBootFailure: 'abort', + 'plugin-b': { + modules: { + 'module-y': { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -323,19 +349,21 @@ describe('createAllowBootFailurePredicate', () => { describe('combined plugin and module configurations', () => { it('should use module config when both plugin and module configs exist', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'abort', - onPluginModuleBootFailure: 'abort', - }, - plugins: { - 'test-plugin': { - onPluginBootFailure: 'continue', - modules: { - 'test-module': { - onPluginModuleBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + modules: { + 'test-module': { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -349,19 +377,21 @@ describe('createAllowBootFailurePredicate', () => { }); it('should use plugin default when module config does not exist', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'abort', - onPluginModuleBootFailure: 'abort', - }, - plugins: { - 'test-plugin': { - onPluginBootFailure: 'continue', - modules: { - 'other-module': { - onPluginModuleBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'abort', + onPluginModuleBootFailure: 'abort', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'continue', + modules: { + 'other-module': { + onPluginModuleBootFailure: 'abort', + }, }, }, }, @@ -377,13 +407,15 @@ describe('createAllowBootFailurePredicate', () => { describe('edge cases', () => { it('should handle empty plugins config', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginBootFailure: 'continue', + }, + plugins: {}, }, - plugins: {}, }, }, }); @@ -392,15 +424,17 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle plugin with empty modules config', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'continue', - }, - plugins: { - 'test-plugin': { - modules: {}, + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + modules: {}, + }, }, }, }, @@ -411,15 +445,17 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle plugin without modules config', () => { - const config = new ConfigReader({ - backend: { - startup: { - default: { - onPluginModuleBootFailure: 'continue', - }, - plugins: { - 'test-plugin': { - onPluginBootFailure: 'abort', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + default: { + onPluginModuleBootFailure: 'continue', + }, + plugins: { + 'test-plugin': { + onPluginBootFailure: 'abort', + }, }, }, }, @@ -430,12 +466,14 @@ describe('createAllowBootFailurePredicate', () => { }); it('should handle case sensitivity correctly', () => { - const config = new ConfigReader({ - backend: { - startup: { - plugins: { - 'Test-Plugin': { - onPluginBootFailure: 'continue', + const config = mockServices.rootConfig({ + data: { + backend: { + startup: { + plugins: { + 'Test-Plugin': { + onPluginBootFailure: 'continue', + }, }, }, }, @@ -468,7 +506,7 @@ describe('createAllowBootFailurePredicate', () => { }, }, }; - const config = new ConfigReader(configData); + const config = mockServices.rootConfig({ data: configData }); const predicate = createAllowBootFailurePredicate(config); // Call predicate multiple times - should use cached values From 32a849124f68f18c62c924825e042731084035f5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 21 Nov 2025 18:36:10 +0100 Subject: [PATCH 9/9] backend-app-api: sort plugin IDs in init collector Signed-off-by: Patrik Oldsberg --- .../src/wiring/createInitializationResultCollector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts index a3faa455ff..b9ee4c227d 100644 --- a/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts +++ b/packages/backend-app-api/src/wiring/createInitializationResultCollector.ts @@ -39,7 +39,7 @@ export function createInitializationResultCollector(options: { } { const logger = options.logger?.child({ type: 'initialization' }); const beginAt = new Date(); - const starting = new Set(options.pluginIds); + const starting = new Set(options.pluginIds.toSorted()); const started = new Set(); let hasDisallowedFailures = false;