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'; + }; +}