Add default startup options

Signed-off-by: Tim Hansen <timbonicush@spotify.com>
This commit is contained in:
Tim Hansen
2025-02-07 13:57:07 -07:00
parent 5622362b3e
commit c5b6f34ec7
5 changed files with 123 additions and 25 deletions
+16 -2
View File
@@ -8,10 +8,24 @@ Added a configuration to permit backend plugin failures on startup:
backend:
...
startup:
plugin-x:
optional: true
plugins:
plugin-x:
optional: true
```
This configuration permits `plugin-x` to fail on startup. Omitting the `startup`
configuration matches the previous behavior, wherein any individual plugin
failure is fatal to backend startup.
The default can also be changed, so that all plugins are considered optional
unless otherwise specified:
```yaml
backend:
startup:
default:
optional: true
plugins:
catalog:
optional: false
```
+22 -3
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { BackendStartupOptions } from './src';
export interface Config {
backend?: {
/** Used by the feature discovery service */
@@ -26,6 +24,27 @@ export interface Config {
exclude?: string[];
};
startup?: BackendStartupOptions;
startup?: {
default?: {
/**
* The default value for `optional` if not specified for a particular plugin. This defaults to
* false, which means `optional: true` must be specified for individual plugins to be considered
* optional. This can also be set to true, which flips the logic for individual plugins so that
* they must be set to `optional: false` to be required.
*/
optional?: boolean;
};
plugins?: {
[pluginId: string]: {
/**
* Used to mark plugins as optional, which allows the backend to start up even in the event
* of a plugin failure. Plugin failures without this configuration are fatal. This can
* enable leaving a crashing plugin installed, but still permit backend startup, which may
* help troubleshoot data-dependent issues.
*/
optional?: boolean;
};
};
};
};
}
@@ -565,7 +565,9 @@ describe('BackendInitializer', () => {
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.rootConfig.factory({
data: { backend: { startup: { test: { optional: true } } } },
data: {
backend: { startup: { plugins: { test: { optional: true } } } },
},
}),
]);
init.add(
@@ -584,6 +586,63 @@ describe('BackendInitializer', () => {
await init.start();
});
it('should permit startup errors if the default is set', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.rootConfig.factory({
data: { backend: { startup: { default: { optional: true } } } },
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await init.start();
});
it('should forward errors for plugins explicitly marked as not optional when the default is true', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
default: { optional: true },
plugins: { test: { optional: false } },
},
},
},
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).rejects.toThrow(
"Plugin 'test' startup failed; caused by Error: NOPE",
);
});
it('should forward errors when multiple plugins fail to start', async () => {
const init = new BackendInitializer([]);
init.add(
@@ -24,7 +24,8 @@ import {
RootLifecycleService,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { BackendStartupOptions, ServiceOrExtensionPoint } from './types';
import { Config } from '@backstage/config';
import { ServiceOrExtensionPoint } from './types';
// Direct internal import to avoid duplication
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type {
@@ -326,19 +327,14 @@ export class BackendInitializer {
'root',
);
const startupOptions =
rootConfig
?.getOptionalConfig('backend.startup')
?.get<BackendStartupOptions>() ?? {};
// Gather backend pluginIds marked as optional, since these should not cause a startup failure
const optionalBackends = Object.entries(startupOptions)
.filter(([_, value]) => value?.optional)
.map(([key]) => key);
// All plugins are initialized in parallel
const results = await Promise.allSettled(
allPluginIds.map(async pluginId => {
const isPluginOptional = this.#getPluginOptionalityPredicate(
pluginId,
rootConfig,
);
try {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
@@ -407,7 +403,7 @@ export class BackendInitializer {
await lifecycleService.startup();
} catch (error: unknown) {
assertError(error);
if (optionalBackends.includes(pluginId)) {
if (isPluginOptional) {
initLogger.onOptionalPluginFailed(pluginId, error);
} else {
initLogger.onPluginFailed(pluginId, error);
@@ -639,6 +635,17 @@ export class BackendInitializer {
}
}
}
#getPluginOptionalityPredicate(pluginId: string, config?: Config): boolean {
const defaultStartupOptionalValue =
config?.getOptionalBoolean('backend.startup.default.optional') ?? false;
return (
config?.getOptionalBoolean(
`backend.startup.plugins.${pluginId}.optional`,
) ?? defaultStartupOptionalValue
);
}
}
function toInternalBackendFeature(
+6 -7
View File
@@ -48,13 +48,12 @@ export type ServiceOrExtensionPoint<T = unknown> =
* @public
*/
export type BackendStartupOptions = {
[pluginId: string]: {
/**
* Used to mark plugins as optional, which allows the backend to start up even in the event
* of a plugin failure. Plugin failures without this configuration are fatal. This can
* enable leaving a crashing plugin installed, but still permit backend startup, which may
* help troubleshoot data-dependent issues.
*/
default?: {
optional?: boolean;
};
plugins?: {
[pluginId: string]: {
optional?: boolean;
};
};
};