catalog-backend: attribute provider connection failures to modules
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
createBackendFeatureLoader,
|
||||
ServiceRef,
|
||||
coreServices,
|
||||
ExtensionPointFactoryContext,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { BackendInitializer } from './BackendInitializer';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
@@ -740,6 +741,74 @@ describe('BackendInitializer', () => {
|
||||
await expect(init.start()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should honor module failure reports from extension points', async () => {
|
||||
const init = new BackendInitializer([
|
||||
...baseFactories,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
startup: {
|
||||
plugins: {
|
||||
test: {
|
||||
modules: { mod: { onPluginModuleBootFailure: 'continue' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
let extensionValue = 0;
|
||||
const extensionPoint = createExtensionPoint<{ getValue(): number }>({
|
||||
id: 'test-extension',
|
||||
});
|
||||
const plugin = createBackendPlugin({
|
||||
pluginId: 'test',
|
||||
register(reg) {
|
||||
let theContext: ExtensionPointFactoryContext | undefined;
|
||||
reg.registerExtensionPoint({
|
||||
extensionPoint,
|
||||
factory: context => {
|
||||
theContext = context;
|
||||
return {
|
||||
getValue: () => 3,
|
||||
};
|
||||
},
|
||||
});
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {
|
||||
theContext?.reportModuleStartupFailure({
|
||||
error: new Error('NOPE'),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const module = createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { extension: extensionPoint },
|
||||
async init({ extension }) {
|
||||
extensionValue = extension.getValue();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
init.add(plugin);
|
||||
init.add(module);
|
||||
|
||||
const { result } = await init.start();
|
||||
const moduleResult = result.plugins
|
||||
.find(p => p.pluginId === 'test')
|
||||
?.modules.find(m => m.moduleId === 'mod');
|
||||
expect(moduleResult?.failure?.allowed).toBe(true);
|
||||
expect(moduleResult?.failure?.error?.message).toBe('NOPE');
|
||||
expect(extensionValue).toBe(3);
|
||||
});
|
||||
|
||||
it('should permit startup errors if the default onPluginModuleBootFailure is continue', async () => {
|
||||
const init = new BackendInitializer([
|
||||
...baseFactories,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LifecycleService,
|
||||
RootLifecycleService,
|
||||
createServiceFactory,
|
||||
ExtensionPointFactoryContext,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { ServiceOrExtensionPoint } from './types';
|
||||
// Direct internal import to avoid duplication
|
||||
@@ -109,10 +110,12 @@ function createRootInstanceMetadataServiceFactory(
|
||||
.filter(registration => registration.featureType === 'registrations')
|
||||
.flatMap(registration => registration.getRegistrations());
|
||||
const plugins = registrations.filter(
|
||||
registration => registration.type === 'plugin',
|
||||
registration =>
|
||||
registration.type === 'plugin' || registration.type === 'plugin-v1.1',
|
||||
);
|
||||
const modules = registrations.filter(
|
||||
registration => registration.type === 'module',
|
||||
registration =>
|
||||
registration.type === 'module' || registration.type === 'module-v1.1',
|
||||
);
|
||||
for (const plugin of plugins) {
|
||||
const { pluginId } = plugin;
|
||||
@@ -153,7 +156,13 @@ export class BackendInitializer {
|
||||
#startPromise?: Promise<{ result: BackendStartupResult }>;
|
||||
#stopPromise?: Promise<void>;
|
||||
#registrations = new Array<InternalBackendRegistrations>();
|
||||
#extensionPoints = new Map<string, { impl: unknown; pluginId: string }>();
|
||||
#extensionPoints = new Map<
|
||||
string,
|
||||
{
|
||||
pluginId: string;
|
||||
factory: (context: ExtensionPointFactoryContext) => unknown;
|
||||
}
|
||||
>();
|
||||
#serviceRegistry: ServiceRegistry;
|
||||
#registeredFeatures = new Array<Promise<BackendFeature>>();
|
||||
#registeredFeatureLoaders = new Array<InternalBackendFeatureLoader>();
|
||||
@@ -166,6 +175,7 @@ export class BackendInitializer {
|
||||
|
||||
async #getInitDeps(
|
||||
deps: { [name: string]: ServiceOrExtensionPoint },
|
||||
resultCollector: ReturnType<typeof createInitializationResultCollector>,
|
||||
pluginId: string,
|
||||
moduleId?: string,
|
||||
) {
|
||||
@@ -180,7 +190,23 @@ export class BackendInitializer {
|
||||
`Illegal dependency: Module '${moduleId}' for plugin '${pluginId}' attempted to depend on extension point '${ref.id}' for plugin '${ep.pluginId}'. Extension points can only be used within their plugin's scope.`,
|
||||
);
|
||||
}
|
||||
result.set(name, ep.impl);
|
||||
if (!moduleId) {
|
||||
throw new Error(
|
||||
`Rejected dependency on extension point ${ref.id} from outside of a module`,
|
||||
);
|
||||
}
|
||||
result.set(
|
||||
name,
|
||||
ep.factory({
|
||||
reportModuleStartupFailure: ({ error }) => {
|
||||
resultCollector.amendPluginModuleResult(
|
||||
pluginId,
|
||||
moduleId,
|
||||
error,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const impl = await this.#serviceRegistry.get(
|
||||
ref as ServiceRef<unknown>,
|
||||
@@ -293,6 +319,7 @@ export class BackendInitializer {
|
||||
const provides = new Set<ExtensionPoint<unknown>>();
|
||||
|
||||
if (r.type === 'plugin' || r.type === 'module') {
|
||||
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
|
||||
for (const [extRef, extImpl] of r.extensionPoints) {
|
||||
if (this.#extensionPoints.has(extRef.id)) {
|
||||
throw new Error(
|
||||
@@ -300,14 +327,28 @@ export class BackendInitializer {
|
||||
);
|
||||
}
|
||||
this.#extensionPoints.set(extRef.id, {
|
||||
impl: extImpl,
|
||||
pluginId: r.pluginId,
|
||||
factory: () => extImpl,
|
||||
});
|
||||
provides.add(extRef);
|
||||
}
|
||||
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
|
||||
// Handle v1.1 format: Array<ExtensionPointRegistration>
|
||||
for (const extReg of r.extensionPoints) {
|
||||
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
|
||||
throw new Error(
|
||||
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
|
||||
);
|
||||
}
|
||||
this.#extensionPoints.set(extReg.extensionPoint.id, {
|
||||
pluginId: r.pluginId,
|
||||
factory: extReg.factory,
|
||||
});
|
||||
provides.add(extReg.extensionPoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.type === 'plugin') {
|
||||
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
|
||||
if (pluginInits.has(r.pluginId)) {
|
||||
throw new Error(`Plugin '${r.pluginId}' is already registered`);
|
||||
}
|
||||
@@ -316,7 +357,7 @@ export class BackendInitializer {
|
||||
consumes: new Set(Object.values(r.init.deps)),
|
||||
init: r.init,
|
||||
});
|
||||
} else if (r.type === 'module') {
|
||||
} else if (r.type === 'module' || r.type === 'module-v1.1') {
|
||||
let modules = moduleInits.get(r.pluginId);
|
||||
if (!modules) {
|
||||
modules = new Map();
|
||||
@@ -391,6 +432,7 @@ export class BackendInitializer {
|
||||
try {
|
||||
const moduleDeps = await this.#getInitDeps(
|
||||
moduleInit.init.deps,
|
||||
resultCollector,
|
||||
pluginId,
|
||||
moduleId,
|
||||
);
|
||||
@@ -414,6 +456,7 @@ export class BackendInitializer {
|
||||
if (pluginInit) {
|
||||
const pluginDeps = await this.#getInitDeps(
|
||||
pluginInit.init.deps,
|
||||
resultCollector,
|
||||
pluginId,
|
||||
);
|
||||
await pluginInit.init.func(pluginDeps);
|
||||
@@ -478,7 +521,7 @@ export class BackendInitializer {
|
||||
const allPlugins = new Set<string>();
|
||||
for (const feature of this.#registrations) {
|
||||
for (const r of feature.getRegistrations()) {
|
||||
if (r.type === 'plugin') {
|
||||
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
|
||||
allPlugins.add(r.pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ export function createInitializationResultCollector(options: {
|
||||
}): {
|
||||
onPluginResult(pluginId: string, error?: Error): void;
|
||||
onPluginModuleResult(pluginId: string, moduleId: string, error?: Error): void;
|
||||
amendPluginModuleResult(
|
||||
pluginId: string,
|
||||
moduleId: string,
|
||||
error: Error,
|
||||
): void;
|
||||
finalize(): BackendStartupResult;
|
||||
} {
|
||||
const logger = options.logger?.child({ type: 'initialization' });
|
||||
@@ -42,6 +47,7 @@ export function createInitializationResultCollector(options: {
|
||||
const starting = new Set(options.pluginIds.toSorted());
|
||||
const started = new Set<string>();
|
||||
|
||||
let hasFinalized = false;
|
||||
let hasDisallowedFailures = false;
|
||||
|
||||
const pluginResults: PluginStartupResult[] = [];
|
||||
@@ -160,7 +166,43 @@ export function createInitializationResultCollector(options: {
|
||||
}
|
||||
}
|
||||
},
|
||||
amendPluginModuleResult(pluginId: string, moduleId: string, error: Error) {
|
||||
if (hasFinalized) {
|
||||
logger?.error(
|
||||
`Plugin '${pluginId}' reported failure for module '${moduleId}' after startup`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const moduleResults = moduleResultsByPlugin.get(pluginId);
|
||||
if (!moduleResults) {
|
||||
throw new Error(
|
||||
`Failed to amend module result for nonexistent plugin '${pluginId}'`,
|
||||
);
|
||||
}
|
||||
const result = moduleResults.find(r => r.moduleId === moduleId);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`Failed to amend module result for nonexistent module '${moduleId}' in plugin '${pluginId}'`,
|
||||
);
|
||||
}
|
||||
const allowed = options.allowBootFailurePredicate(pluginId, moduleId);
|
||||
if (allowed) {
|
||||
logger?.error(
|
||||
`Plugin '${pluginId}' reported failure for module '${moduleId}' during startup, but boot failure is permitted for this plugin module so startup will continue.`,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
hasDisallowedFailures = true;
|
||||
logger?.error(
|
||||
`Plugin '${pluginId}' reported failure for module '${moduleId}' during startup.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
result.failure = { error, allowed };
|
||||
},
|
||||
finalize() {
|
||||
hasFinalized = true;
|
||||
logger?.info(`Plugin initialization complete${getInitStatus()}`);
|
||||
|
||||
if (timeout) {
|
||||
|
||||
Reference in New Issue
Block a user