Merge branch 'backstage:master' into master

This commit is contained in:
fvivas
2025-04-29 11:09:57 +02:00
committed by GitHub
14 changed files with 357 additions and 103 deletions
+35
View File
@@ -0,0 +1,35 @@
---
'@backstage/backend-app-api': patch
---
Added a configuration to permit backend plugin module failures on startup:
```yaml
backend:
...
startup:
plugins:
plugin-x:
modules:
module-y:
onPluginModuleBootFailure: continue
```
This configuration permits `plugin-x` with `module-y` to fail on startup. Omitting the
`onPluginModuleBootFailure` configuration matches the previous behavior, wherein any
individual plugin module failure is forwarded to the plugin and aborts backend startup.
The default can also be changed, so that continuing on failure is the default
unless otherwise specified:
```yaml
backend:
startup:
default:
onPluginModuleBootFailure: continue
plugins:
catalog:
modules:
github:
onPluginModuleBootFailure: abort
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Internal refactor of opaque type handling.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added `info` object to the context of the alpha CLI.
+1 -13
View File
@@ -453,7 +453,7 @@ const config: Config = {
},
{
label: 'Adopting',
to: 'https://backstage.spotify.com',
to: 'https://backstage.io/docs/getting-started/',
},
{
label: 'Subscribe to our newsletter',
@@ -468,18 +468,6 @@ const config: Config = {
{
title: 'More',
items: [
{
label: 'Open Source @ Spotify',
to: 'https://spotify.github.io/',
},
{
label: 'Spotify Engineering Blog',
to: 'https://engineering.atspotify.com/',
},
{
label: 'Spotify for Developers',
to: 'https://developer.spotify.com/',
},
{
label: 'GitHub',
to: 'https://github.com/backstage/',
+21
View File
@@ -34,6 +34,14 @@ export interface Config {
* `onPluginBootFailure: abort` to be required.
*/
onPluginBootFailure?: 'continue' | 'abort';
/**
* The default value for `onPluginModuleBootFailure` if not specified for a particular plugin module.
* This defaults to 'abort', which means `onPluginModuleBootFailure: continue` must be specified
* for backend startup to continue on plugin module boot failure. This can also be set to
* 'continue', which flips the logic for individual plugin modules so that they must be set to
* `onPluginModuleBootFailure: abort` to be required.
*/
onPluginModuleBootFailure?: 'continue' | 'abort';
};
plugins?: {
[pluginId: string]: {
@@ -46,6 +54,19 @@ export interface Config {
* setting).
*/
onPluginBootFailure?: 'continue' | 'abort';
modules?: {
[moduleId: string]: {
/**
* Used to control backend startup behavior when this plugin module fails to boot up. Setting
* this to `continue` allows the backend to continue starting up, even if this plugin
* module fails. This can enable leaving a crashing plugin installed, but still permit backend
* startup, which may help troubleshoot data-dependent issues. Plugin module failures for plugin modules
* set to `abort` are fatal (this is the default unless overridden by the `default`
* setting).
*/
onPluginModuleBootFailure?: 'continue' | 'abort';
};
};
};
};
};
@@ -541,7 +541,7 @@ describe('BackendInitializer', () => {
});
it('should forward errors when plugins fail to start', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -562,8 +562,7 @@ describe('BackendInitializer', () => {
it('should permit startup errors for plugins with onPluginBootFailure: continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -590,8 +589,7 @@ describe('BackendInitializer', () => {
it('should permit startup errors if the default onPluginBootFailure is continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -618,8 +616,7 @@ describe('BackendInitializer', () => {
it('should forward errors for plugins explicitly marked to abort when the default is continue', async () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -649,6 +646,130 @@ describe('BackendInitializer', () => {
);
});
it('should forward errors when plugin modules fail to start', async () => {
const init = new BackendInitializer(baseFactories);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' startup failed; caused by Error: NOPE",
);
});
it('should permit startup errors for plugin modules with onPluginModuleBootFailure: continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
plugins: {
test: {
modules: { mod: { onPluginModuleBootFailure: 'continue' } },
},
},
},
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).resolves.not.toThrow();
});
it('should permit startup errors if the default onPluginModuleBootFailure is continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: { default: { onPluginModuleBootFailure: 'continue' } },
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).resolves.not.toThrow();
});
it('should forward errors for plugin modules explicitly marked to abort when the default is continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
default: { onPluginModuleBootFailure: 'continue' },
plugins: {
test: {
modules: { mod: { onPluginModuleBootFailure: 'abort' } },
},
},
},
},
},
}),
]);
init.add(testPlugin);
init.add(
createBackendModule({
pluginId: 'test',
moduleId: 'mod',
register(reg) {
reg.registerInit({
deps: {},
async init() {
throw new Error('NOPE');
},
});
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for 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(
@@ -365,17 +365,38 @@ export class BackendInitializer {
}
await tree.parallelTopologicalTraversal(
async ({ moduleId, moduleInit }) => {
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,
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,
);
});
} catch (error: unknown) {
assertError(error);
if (isModuleBootFailurePermitted) {
initLogger.onPermittedPluginModuleFailure(
pluginId,
moduleId,
error,
);
} else {
initLogger.onPluginModuleFailed(pluginId, moduleId, error);
throw error;
}
}
},
);
}
@@ -649,6 +670,24 @@ export class BackendInitializer {
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(
@@ -29,6 +29,12 @@ export function createInitializationLogger(
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' });
@@ -87,6 +93,26 @@ export function createInitializationLogger(
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()}`);
@@ -56,12 +56,11 @@ Commands:
### `backstage-cli-alpha config docs`
```
Usage: <none>
Usage: backstage-cli config docs [options]
Options:
--help
--package
--version
--package <name>
-h, --help
```
### `backstage-cli-alpha config schema`
+12 -12
View File
@@ -40,22 +40,21 @@ export default createCliPlugin({
reg.addCommand({
path: ['config', 'docs'],
description: 'Browse the configuration reference documentation',
execute: async ({ args }) => {
const argv = await yargs
.options({
package: { type: 'string' },
})
.help()
.parse(args);
const m =
(await require('./commands/docs')) as typeof import('./commands/docs');
await m.default(argv);
execute: async ({ args, info }) => {
await new Command(info.usage)
.option(
'--package <name>',
'Only include the schema that applies to the given package',
)
.description(info.description)
.action(lazy(() => import('./commands/docs'), 'default'))
.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['config:print'],
description: 'Print the app configuration for the current package',
execute: async ({ args }) => {
execute: async ({ args, info }) => {
const argv = await yargs
.options({
package: { type: 'string' },
@@ -63,8 +62,9 @@ export default createCliPlugin({
frontend: { type: 'boolean' },
'with-secrets': { type: 'boolean' },
format: { type: 'string' },
config: { type: 'string', array: true },
config: { type: 'string', array: true, default: [] },
})
.usage('$0', info.description)
.help()
.parse(args);
const m =
+12 -25
View File
@@ -15,7 +15,7 @@
*/
import { CommandGraph } from './CommandGraph';
import { CliFeature, InternalCliFeature, InternalCliPlugin } from './types';
import { CliFeature, OpaqueCliPlugin } from './types';
import { CommandRegistry } from './CommandRegistry';
import { Command } from 'commander';
import { version } from '../lib/version';
@@ -42,10 +42,11 @@ export class CliInitializer {
}
async #register(feature: CliFeature) {
if (isCliPlugin(feature)) {
await feature.init(this.commandRegistry);
if (OpaqueCliPlugin.isType(feature)) {
const internal = OpaqueCliPlugin.toInternal(feature);
await internal.init(this.commandRegistry);
} else {
throw new Error(`Unsupported feature type: ${feature.$$type}`);
throw new Error(`Unsupported feature type: ${(feature as any).$$type}`);
}
}
@@ -62,9 +63,11 @@ export class CliInitializer {
async run() {
await this.#doInit();
const programName = 'backstage-cli';
const program = new Command();
program
.name('backstage-cli')
.name(programName)
.version(version)
.allowUnknownOption(true)
.allowExcessArguments(true);
@@ -117,6 +120,10 @@ export class CliInitializer {
}
await node.command.execute({
args: [...positionalArgs, ...args.unknown],
info: {
usage: [programName, ...node.command.path].join(' '),
description: node.command.description,
},
});
process.exit(0);
} catch (error) {
@@ -146,26 +153,6 @@ export class CliInitializer {
}
}
function toInternalCliFeature(feature: CliFeature): InternalCliFeature {
if (feature.$$type !== '@backstage/CliFeature') {
throw new Error(`Invalid CliFeature, bad type '${feature.$$type}'`);
}
const internal = feature as InternalCliFeature;
if (internal.version !== 'v1') {
throw new Error(`Invalid CliFeature, bad version '${internal.version}'`);
}
return internal;
}
function isCliPlugin(feature: CliFeature): feature is InternalCliPlugin {
const internal = toInternalCliFeature(feature);
if (internal.featureType === 'plugin') {
return true;
}
// Backwards compatibility for v1 registrations that use duck typing
return 'plugin' in internal;
}
/** @internal */
export function unwrapFeature(
feature: CliFeature | { default: CliFeature },
@@ -0,0 +1,20 @@
/*
* Copyright 2023 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.
*/
// Single re-export to avoid doing this import in multiple places, but still
// avoid duplicate declarations because this one is a bit tricky.
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
export { describeParentCallSite } from '../../../frontend-plugin-api/src/routing/describeParentCallSite';
+10 -11
View File
@@ -14,19 +14,18 @@
* limitations under the License.
*/
import { CommandRegistry } from './CommandRegistry';
import { InternalCliPlugin } from './types';
import { describeParentCallSite } from './describeParentCallSite';
import { BackstageCommand, CliPlugin, OpaqueCliPlugin } from './types';
export function createCliPlugin(options: {
pluginId: string;
init: (registry: CommandRegistry) => Promise<void>;
}): InternalCliPlugin {
return {
id: options.pluginId,
init: (registry: {
addCommand: (command: BackstageCommand) => void;
}) => Promise<void>;
}): CliPlugin {
return OpaqueCliPlugin.createInstance('v1', {
pluginId: options.pluginId,
init: options.init,
$$type: '@backstage/CliFeature',
version: 'v1',
featureType: 'plugin',
description: 'A Backstage CLI plugin',
};
description: `created at '${describeParentCallSite()}'`,
});
}
+30 -21
View File
@@ -13,35 +13,44 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CommandRegistry } from './CommandRegistry';
import { OpaqueType } from '@internal/opaque';
export interface BackstageCommand {
path: string[];
description: string;
deprecated?: boolean;
execute: (options: { args: string[] }) => Promise<void>;
execute: (context: {
args: string[];
info: {
/**
* The usage string of the current command, for example: "backstage-cli repo test"
*/
usage: string;
/**
* The description provided for the command
*/
description: string;
};
}) => Promise<void>;
}
export interface CliFeature {
$$type: '@backstage/CliFeature';
}
export type CliFeature = CliPlugin;
export interface CliPlugin {
id: string;
init: (registry: CommandRegistry) => Promise<void>;
$$type: '@backstage/CliFeature';
readonly pluginId: string;
readonly $$type: '@backstage/CliPlugin';
}
/**
* @public
*/
export interface InternalCliPlugin extends CliFeature {
version: 'v1';
featureType: 'plugin';
description: string;
id: string;
init: (registry: CommandRegistry) => Promise<void>;
}
/** @internal */
export type InternalCliFeature = InternalCliPlugin;
export const OpaqueCliPlugin = OpaqueType.create<{
public: CliPlugin;
versions: {
readonly version: 'v1';
readonly description: string;
init: (registry: {
addCommand: (command: BackstageCommand) => void;
}) => Promise<void>;
};
}>({
type: '@backstage/CliPlugin',
versions: ['v1'],
});