catalog-backend: attribute provider connection failures to modules

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-24 16:52:37 +01:00
parent 24eb7d7933
commit f1d29b4d4d
23 changed files with 649 additions and 137 deletions
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Updated `startTestBackend` to support factory-based extension points (v1.1 format) in addition to the existing direct implementation format.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Failure to connect catalog providers are now attributed to the module that provided the failing provider. This means that such failures will be reported as module startup failures rather than a failure to start the catalog plugin, and will therefore respect `onPluginModuleBootFailure` configuration instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': minor
---
Added support for extension point factories, along with the ability to report module startup failures via the extension point factory context.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': minor
---
Added support for extension point factories. This makes it possible to call `registerExtensionPoint` with a single options argument and provide an factory for the extension point rather than a direct implementation. The factory is passed a context with a `reportModuleStartupFailure` method that makes it possible for plugins to report and attribute startup errors to the module that consumed the extension point.
@@ -65,6 +65,64 @@ export const scaffolderPlugin = createBackendPlugin(
Note that we create a closure that adds to a shared `actions` structure when `addAction` is called by users of your extension point. It is safe for us to then access our `actions` in the `init` method of our plugin, since all modules that extend our plugin will be completely initialized before our plugin gets initialized. That means that at the point where our `init` method is called, all actions have been added and can be accessed.
## Factory-Based Extension Points
In some cases, you may want to be able to attribute startup failures to modules that provided an extension, rather than failing the plugin startup entirely. To do this, you can use a variant of `registerExtensionPoint` that instead of providing a direct implementation, registers a factory function that produces the implementation. This factory receives a `ExtensionPointFactoryContext` with a `reportModuleStartupFailure` method that lets you report startup failures and attribute them to the module.
Here's an example of registering an extension point using a factory:
```ts
import {
createBackendPlugin,
ExtensionPointFactoryContext,
} from '@backstage/backend-plugin-api';
import { assertError, ForwardedError } from '@backstage/errors';
import { createProviderConnection, Provider } from './internal';
type ProviderEntry = {
provider: Provider;
context: ExtensionPointFactoryContext;
};
export const examplePlugin = createBackendPlugin({
pluginId: 'example',
register(env) {
const providers: ProviderEntry[] = [];
// Using the variant of registerExtensionPoint that takes an options object.
env.registerExtensionPoint({
extensionPoint: exampleProvidersExtensionPoint,
// The factory function produces a separate instance for each module.
factory: context => ({
addProvider(provider) {
// Store the context together with the provider so we can report failures later
providers.push({ provider, context });
},
}),
});
env.registerInit({
deps: { database: coreServices.database },
async init({ database }) {
for (const { provider, context } of providers) {
const connection = await createProviderConnection(provider, database);
try {
// This connects each provider that was installed by a module
await provider.connect(connection);
} catch (error: unknown) {
// If the connection fails, we can report this as a failure of the module rather than the plugin
assertError(error);
context.reportModuleStartupFailure({
error: new ForwardedError('Failed to connect provider', error),
});
}
}
},
});
},
});
```
## Module Extension Points
Just like plugins, modules can also provide their own extension points. The API for registering and using extension points is the same as for plugins. However, modules should typically only use extension points to allow for complex internal customizations by users of the plugin module. It is therefore preferred to export the extension point directly from the module package, rather than creating a separate node library for that purpose. Extension points exported by a module are used the same way as extension points exported by a plugin, you create your own separate module and declare a dependency on the extension point that you want to interact with.
@@ -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) {
+14 -1
View File
@@ -102,6 +102,11 @@ export interface BackendModuleRegistrationPoints {
impl: TExtensionPoint,
): void;
// (undocumented)
registerExtensionPoint<TExtensionPoint>(options: {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (context: ExtensionPointFactoryContext) => TExtensionPoint;
}): void;
// (undocumented)
registerInit<
TDeps extends {
[name in string]: ServiceRef<unknown> | ExtensionPoint<unknown>;
@@ -114,11 +119,14 @@ export interface BackendModuleRegistrationPoints {
// @public
export interface BackendPluginRegistrationPoints {
// (undocumented)
registerExtensionPoint<TExtensionPoint>(
ref: ExtensionPoint<TExtensionPoint>,
impl: TExtensionPoint,
): void;
registerExtensionPoint<TExtensionPoint>(options: {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (context: ExtensionPointFactoryContext) => TExtensionPoint;
}): void;
// (undocumented)
registerInit<
TDeps extends {
@@ -388,6 +396,11 @@ export type ExtensionPoint<T> = {
$$type: '@backstage/ExtensionPoint';
};
// @public
export interface ExtensionPointFactoryContext {
reportModuleStartupFailure(options: { error: Error }): void;
}
// @public
export interface HttpAuthService {
credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
@@ -35,7 +35,7 @@ describe('createBackendModule', () => {
expect(module.getRegistrations).toEqual(expect.any(Function));
expect(module.getRegistrations()).toEqual([
{
type: 'module',
type: 'module-v1.1',
pluginId: 'x',
moduleId: 'y',
extensionPoints: [],
@@ -17,8 +17,9 @@
import { BackendFeature } from '../types';
import {
BackendModuleRegistrationPoints,
InternalBackendModuleRegistration,
InternalBackendPluginRegistration,
ExtensionPoint,
ExtensionPointFactoryContext,
InternalBackendModuleRegistrationV1_1,
InternalBackendRegistrations,
} from './types';
@@ -55,16 +56,43 @@ export function createBackendModule(
options: CreateBackendModuleOptions,
): BackendFeature {
function getRegistrations() {
const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] =
const extensionPoints: InternalBackendModuleRegistrationV1_1['extensionPoints'] =
[];
let init: InternalBackendModuleRegistration['init'] | undefined = undefined;
let init: InternalBackendModuleRegistrationV1_1['init'] | undefined =
undefined;
options.register({
registerExtensionPoint(ext, impl) {
registerExtensionPoint<TExtensionPoint>(
extOrOpts:
| ExtensionPoint<TExtensionPoint>
| {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (
context: ExtensionPointFactoryContext,
) => TExtensionPoint;
},
impl?: TExtensionPoint,
) {
if (init) {
throw new Error('registerExtensionPoint called after registerInit');
}
extensionPoints.push([ext, impl]);
if (
typeof extOrOpts === 'object' &&
extOrOpts !== null &&
'extensionPoint' in extOrOpts
) {
extensionPoints.push({
extensionPoint: extOrOpts.extensionPoint,
factory: extOrOpts.factory as (
context: ExtensionPointFactoryContext,
) => unknown,
});
} else {
extensionPoints.push({
extensionPoint: extOrOpts,
factory: () => impl,
});
}
},
registerInit(regInit) {
if (init) {
@@ -85,7 +113,7 @@ export function createBackendModule(
return [
{
type: 'module',
type: 'module-v1.1',
pluginId: options.pluginId,
moduleId: options.moduleId,
extensionPoints,
@@ -34,7 +34,7 @@ describe('createBackendPlugin', () => {
expect(plugin.getRegistrations).toEqual(expect.any(Function));
expect(plugin.getRegistrations()).toEqual([
{
type: 'plugin',
type: 'plugin-v1.1',
pluginId: 'x',
extensionPoints: [],
init: {
@@ -17,7 +17,9 @@
import { BackendFeature } from '../types';
import {
BackendPluginRegistrationPoints,
InternalBackendPluginRegistration,
ExtensionPoint,
ExtensionPointFactoryContext,
InternalBackendPluginRegistrationV1_1,
InternalBackendRegistrations,
} from './types';
@@ -49,16 +51,43 @@ export function createBackendPlugin(
options: CreateBackendPluginOptions,
): BackendFeature {
function getRegistrations() {
const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] =
const extensionPoints: InternalBackendPluginRegistrationV1_1['extensionPoints'] =
[];
let init: InternalBackendPluginRegistration['init'] | undefined = undefined;
let init: InternalBackendPluginRegistrationV1_1['init'] | undefined =
undefined;
options.register({
registerExtensionPoint(ext, impl) {
registerExtensionPoint<TExtensionPoint>(
extOrOpts:
| ExtensionPoint<TExtensionPoint>
| {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (
context: ExtensionPointFactoryContext,
) => TExtensionPoint;
},
impl?: TExtensionPoint,
) {
if (init) {
throw new Error('registerExtensionPoint called after registerInit');
}
extensionPoints.push([ext, impl]);
if (
typeof extOrOpts === 'object' &&
extOrOpts !== null &&
'extensionPoint' in extOrOpts
) {
extensionPoints.push({
extensionPoint: extOrOpts.extensionPoint,
factory: extOrOpts.factory as (
context: ExtensionPointFactoryContext,
) => unknown,
});
} else {
extensionPoints.push({
extensionPoint: extOrOpts,
factory: () => impl,
});
}
},
registerInit(regInit) {
if (init) {
@@ -79,7 +108,7 @@ export function createBackendPlugin(
return [
{
type: 'plugin',
type: 'plugin-v1.1',
pluginId: options.pluginId,
extensionPoints,
init,
@@ -30,6 +30,7 @@ export type {
BackendModuleRegistrationPoints,
BackendPluginRegistrationPoints,
ExtensionPoint,
ExtensionPointFactoryContext,
} from './types';
export type {
@@ -36,6 +36,20 @@ export type ExtensionPoint<T> = {
$$type: '@backstage/ExtensionPoint';
};
/**
* Context provided to extension point factories.
*
* @public
*/
export interface ExtensionPointFactoryContext {
/**
* Report a startup failure that happened as part of using an extension that
* the module provided. This should be called before the plugin's `init`
* function returns.
*/
reportModuleStartupFailure(options: { error: Error }): void;
}
/** @ignore */
type DepsToInstances<
TDeps extends {
@@ -53,10 +67,20 @@ type DepsToInstances<
* @public
*/
export interface BackendPluginRegistrationPoints {
/**
* Registers an implementation for an extension point.
*/
registerExtensionPoint<TExtensionPoint>(
ref: ExtensionPoint<TExtensionPoint>,
impl: TExtensionPoint,
): void;
/**
* Registers a factory that produces a separate implementation for an extension point for each module.
*/
registerExtensionPoint<TExtensionPoint>(options: {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (context: ExtensionPointFactoryContext) => TExtensionPoint;
}): void;
registerInit<
TDeps extends {
[name in string]: ServiceRef<unknown>;
@@ -77,6 +101,10 @@ export interface BackendModuleRegistrationPoints {
ref: ExtensionPoint<TExtensionPoint>,
impl: TExtensionPoint,
): void;
registerExtensionPoint<TExtensionPoint>(options: {
extensionPoint: ExtensionPoint<TExtensionPoint>;
factory: (context: ExtensionPointFactoryContext) => TExtensionPoint;
}): void;
registerInit<
TDeps extends {
[name in string]: ServiceRef<unknown> | ExtensionPoint<unknown>;
@@ -92,7 +120,10 @@ export interface InternalBackendRegistrations extends BackendFeature {
version: 'v1';
featureType: 'registrations';
getRegistrations(): Array<
InternalBackendPluginRegistration | InternalBackendModuleRegistration
| InternalBackendPluginRegistration
| InternalBackendModuleRegistration
| InternalBackendPluginRegistrationV1_1
| InternalBackendModuleRegistrationV1_1
>;
}
@@ -119,6 +150,35 @@ export interface InternalBackendModuleRegistration {
};
}
/** @internal */
export type ExtensionPointRegistration = {
extensionPoint: ExtensionPoint<unknown>;
factory: (context: ExtensionPointFactoryContext) => unknown;
};
/** @internal */
export interface InternalBackendPluginRegistrationV1_1 {
pluginId: string;
type: 'plugin-v1.1';
extensionPoints: Array<ExtensionPointRegistration>;
init: {
deps: Record<string, ServiceRef<unknown>>;
func(deps: Record<string, unknown>): Promise<void>;
};
}
/** @internal */
export interface InternalBackendModuleRegistrationV1_1 {
pluginId: string;
moduleId: string;
type: 'module-v1.1';
extensionPoints: Array<ExtensionPointRegistration>;
init: {
deps: Record<string, ServiceRef<unknown> | ExtensionPoint<unknown>>;
func(deps: Record<string, unknown>): Promise<void>;
};
}
/**
* @public
*/
@@ -21,6 +21,7 @@ import {
createServiceRef,
coreServices,
createBackendPlugin,
ExtensionPointFactoryContext,
} from '@backstage/backend-plugin-api';
import { Router } from 'express';
import request from 'supertest';
@@ -408,4 +409,114 @@ describe('TestBackend', () => {
}),
).rejects.toThrow('nah');
});
it('should support factory-based extension points', async () => {
expect.assertions(2);
const extensionPoint = createExtensionPoint<{
getValue(): number;
}>({ id: 'test-factory' });
const testPlugin = createBackendPlugin({
pluginId: 'test',
register(env) {
const instances: Array<{ getValue(): number }> = [];
env.registerExtensionPoint({
extensionPoint,
factory: () => {
const instance = {
getValue: () => 42,
};
instances.push(instance);
return instance;
},
});
env.registerInit({
deps: {},
async init() {
// Factory is called during module initialization, which happens before plugin init
expect(instances).toHaveLength(1);
},
});
},
});
const testModule = createBackendModule({
pluginId: 'test',
moduleId: 'test-module',
register(env) {
env.registerInit({
deps: { ext: extensionPoint },
async init({ ext }) {
expect(ext.getValue()).toBe(42);
},
});
},
});
await startTestBackend({
features: [testPlugin, testModule],
});
});
it('should support reportModuleStartupFailure in factory-based extension points', async () => {
const extensionPoint = createExtensionPoint<{
getValue(): number;
}>({ id: 'test-failure' });
const testPlugin = createBackendPlugin({
pluginId: 'test',
register(env) {
let capturedContext: ExtensionPointFactoryContext | undefined;
env.registerExtensionPoint({
extensionPoint,
factory: context => {
capturedContext = context;
return {
getValue: () => 42,
};
},
});
env.registerInit({
deps: {},
async init() {
// Plugin init runs after all modules have been initialized
// At this point, the module result exists and we can report a failure
capturedContext?.reportModuleStartupFailure({
error: new Error('NOPE'),
});
},
});
},
});
const testModule = createBackendModule({
pluginId: 'test',
moduleId: 'test-module',
register(env) {
env.registerInit({
deps: { ext: extensionPoint },
async init({ ext }) {
// Use the extension point - this creates the context
ext.getValue();
},
});
},
});
await expect(
startTestBackend({
features: [testPlugin, testModule],
}),
).rejects.toThrow(
new Error(
`Backend startup failed due to the following errors:
Module 'test-module' for plugin 'test' startup failed; caused by Error: NOPE`,
),
);
});
});
@@ -107,9 +107,15 @@ function createPluginsForOrphanModules(features: Array<BackendFeature>) {
if (isInternalBackendRegistrations(feature)) {
const registrations = feature.getRegistrations();
for (const registration of registrations) {
if (registration.type === 'plugin') {
if (
registration.type === 'plugin' ||
registration.type === 'plugin-v1.1'
) {
pluginIds.add(registration.pluginId);
} else if (registration.type === 'module') {
} else if (
registration.type === 'module' ||
registration.type === 'module-v1.1'
) {
modulePluginIds.add(registration.pluginId);
}
}
@@ -160,7 +166,7 @@ function createExtensionPointTestModules(
const extensionPointsByPlugin = new Map<string, string[]>();
for (const registration of registrations) {
if (registration.type === 'module') {
if (registration.type === 'module' || registration.type === 'module-v1.1') {
const testDep = Object.values(registration.init.deps).filter(dep =>
extensionPointsToSort.has(dep.id),
);
@@ -26,6 +26,13 @@ import {
EntityProviderRefreshOptions,
EntityProviderMutation,
} from '@backstage/plugin-catalog-node';
import type { ExtensionPointFactoryContext } from '@backstage/backend-plugin-api';
import { assertError } from '@backstage/errors';
export type EntityProviderEntry = {
provider: EntityProvider;
context?: ExtensionPointFactoryContext;
};
class Connection implements EntityProviderConnection {
readonly validateEntityEnvelope = entityEnvelopeSchemaValidator();
@@ -98,15 +105,25 @@ class Connection implements EntityProviderConnection {
export async function connectEntityProviders(
db: ProviderDatabase,
providers: EntityProvider[],
providers: EntityProviderEntry[],
) {
await Promise.all(
providers.map(async provider => {
providers.map(async entry => {
const { provider, context } = entry;
const connection = new Connection({
id: provider.getProviderName(),
providerDatabase: db,
});
return provider.connect(connection);
try {
await provider.connect(connection);
} catch (error: unknown) {
if (context) {
assertError(error);
context.reportModuleStartupFailure({ error });
return;
}
throw error;
}
}),
);
}
@@ -50,7 +50,6 @@ import {
import {
CatalogProcessor,
CatalogProcessorParser,
EntityProvider,
LocationAnalyzer,
PlaceholderResolver,
ScmLocationAnalyzer,
@@ -78,7 +77,10 @@ import {
createRandomProcessingInterval,
ProcessingIntervalFunction,
} from '../processing/refresh';
import { connectEntityProviders } from '../processing/connectEntityProviders';
import {
connectEntityProviders,
EntityProviderEntry,
} from '../processing/connectEntityProviders';
import { evictEntitiesFromOrphanedProviders } from '../processing/evictEntitiesFromOrphanedProviders';
import { DefaultCatalogProcessingEngine } from '../processing/DefaultCatalogProcessingEngine';
import { DefaultCatalogProcessingOrchestrator } from '../processing/DefaultCatalogProcessingOrchestrator';
@@ -156,7 +158,7 @@ export class CatalogBuilder {
private entityPoliciesReplace: boolean;
private placeholderResolvers: Record<string, PlaceholderResolver>;
private fieldFormatValidators: Partial<Validators>;
private entityProviders: EntityProvider[];
private entityProviders: EntityProviderEntry[];
private processors: CatalogProcessor[];
private locationAnalyzers: ScmLocationAnalyzer[];
private processorsReplace: boolean;
@@ -283,7 +285,7 @@ export class CatalogBuilder {
* @param providers - One or more entity providers
*/
addEntityProvider(
...providers: Array<EntityProvider | Array<EntityProvider>>
...providers: Array<EntityProviderEntry | Array<EntityProviderEntry>>
): CatalogBuilder {
this.entityProviders.push(...providers.flat());
return this;
@@ -526,13 +528,21 @@ export class CatalogBuilder {
const locationStore = new DefaultLocationStore(dbClient);
const configLocationProvider = new ConfigLocationEntityProvider(config);
const entityProviders = filterProviders(
lodash.uniqBy(
[...this.entityProviders, locationStore, configLocationProvider],
provider => provider.getProviderName(),
),
const entityProviderEntries = lodash.uniqBy(
[
...this.entityProviders,
{ provider: locationStore },
{ provider: configLocationProvider },
],
entry => entry.provider.getProviderName(),
);
const enabledProviderEntries = filterProviders(
entityProviderEntries,
config,
);
const enabledProviders = enabledProviderEntries.map(
entry => entry.provider,
);
const processingEngine = new DefaultCatalogProcessingEngine({
config,
@@ -583,7 +593,7 @@ export class CatalogBuilder {
enableRelationsCompatibility,
});
await connectEntityProviders(providerDatabase, entityProviders);
await connectEntityProviders(providerDatabase, enabledProviderEntries);
return {
processingEngine: {
@@ -594,7 +604,7 @@ export class CatalogBuilder {
) {
await evictEntitiesFromOrphanedProviders({
db: providerDatabase,
providers: entityProviders,
providers: enabledProviders,
logger,
});
}
@@ -23,7 +23,6 @@ import {
CatalogProcessor,
CatalogProcessorParser,
catalogServiceRef,
EntityProvider,
LocationAnalyzer,
PlaceholderResolver,
ScmLocationAnalyzer,
@@ -37,7 +36,6 @@ import {
CatalogPermissionExtensionPoint,
catalogPermissionExtensionPoint,
CatalogPermissionRuleInput,
CatalogProcessingExtensionPoint,
catalogProcessingExtensionPoint,
} from '@backstage/plugin-catalog-node/alpha';
import { eventsServiceRef } from '@backstage/plugin-events-node';
@@ -46,6 +44,7 @@ import { merge } from 'lodash';
import { CatalogBuilder } from './CatalogBuilder';
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
import { createCatalogActions } from '../actions';
import type { EntityProviderEntry } from '../processing/connectEntityProviders';
class CatalogLocationsExtensionPointImpl
implements CatalogLocationsExtensionPoint
@@ -61,63 +60,6 @@ class CatalogLocationsExtensionPointImpl
}
}
class CatalogProcessingExtensionPointImpl
implements CatalogProcessingExtensionPoint
{
#processors = new Array<CatalogProcessor>();
#entityProviders = new Array<EntityProvider>();
#placeholderResolvers: Record<string, PlaceholderResolver> = {};
#onProcessingErrorHandler?: (event: {
unprocessedEntity: Entity;
errors: Error[];
}) => Promise<void> | void;
addProcessor(
...processors: Array<CatalogProcessor | Array<CatalogProcessor>>
): void {
this.#processors.push(...processors.flat());
}
addEntityProvider(
...providers: Array<EntityProvider | Array<EntityProvider>>
): void {
this.#entityProviders.push(...providers.flat());
}
addPlaceholderResolver(key: string, resolver: PlaceholderResolver) {
if (key in this.#placeholderResolvers)
throw new Error(
`A placeholder resolver for '${key}' has already been set up, please check your config.`,
);
this.#placeholderResolvers[key] = resolver;
}
setOnProcessingErrorHandler(
handler: (event: {
unprocessedEntity: Entity;
errors: Error[];
}) => Promise<void> | void,
) {
this.#onProcessingErrorHandler = handler;
}
get processors() {
return this.#processors;
}
get entityProviders() {
return this.#entityProviders;
}
get placeholderResolvers() {
return this.#placeholderResolvers;
}
get onProcessingErrorHandler() {
return this.#onProcessingErrorHandler;
}
}
class CatalogPermissionExtensionPointImpl
implements CatalogPermissionExtensionPoint
{
@@ -179,12 +121,43 @@ class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint {
export const catalogPlugin = createBackendPlugin({
pluginId: 'catalog',
register(env) {
const processingExtensions = new CatalogProcessingExtensionPointImpl();
// plugins depending on this API will be initialized before this plugins init method is executed.
env.registerExtensionPoint(
catalogProcessingExtensionPoint,
processingExtensions,
);
const processors = new Array<CatalogProcessor>();
const entityProviders = new Array<EntityProviderEntry>();
const placeholderResolvers: Record<string, PlaceholderResolver> = {};
let onProcessingError:
| ((event: {
unprocessedEntity: Entity;
errors: Error[];
}) => Promise<void> | void)
| undefined = undefined;
env.registerExtensionPoint({
extensionPoint: catalogProcessingExtensionPoint,
factory: context => ({
addProcessor: (...newProcessors) => {
processors.push(...newProcessors.flat());
},
addEntityProvider: (...providers) => {
entityProviders.push(
...providers.flat().map(provider => ({
provider,
context,
})),
);
},
addPlaceholderResolver: (key, resolver) => {
if (key in placeholderResolvers) {
throw new Error(
`A placeholder resolver for '${key}' has already been set up, please check your config.`,
);
}
placeholderResolvers[key] = resolver;
},
setOnProcessingErrorHandler: handler => {
onProcessingError = handler;
},
}),
});
let locationAnalyzerFactory:
| ((options: {
@@ -274,20 +247,18 @@ export const catalogPlugin = createBackendPlugin({
events,
});
if (processingExtensions.onProcessingErrorHandler) {
builder.subscribe({
onProcessingError: processingExtensions.onProcessingErrorHandler,
});
if (onProcessingError) {
builder.subscribe({ onProcessingError });
}
builder.addProcessor(...processingExtensions.processors);
builder.addEntityProvider(...processingExtensions.entityProviders);
builder.addProcessor(...processors);
builder.addEntityProvider(...entityProviders);
if (modelExtensions.entityDataParser) {
builder.setEntityDataParser(modelExtensions.entityDataParser);
}
Object.entries(processingExtensions.placeholderResolvers).forEach(
([key, resolver]) => builder.setPlaceholderResolver(key, resolver),
Object.entries(placeholderResolvers).forEach(([key, resolver]) =>
builder.setPlaceholderResolver(key, resolver),
);
if (locationAnalyzerFactory) {
const { locationAnalyzer } = await locationAnalyzerFactory({
@@ -135,14 +135,16 @@ describe('filterProviders', () => {
it('should filter providers', () => {
const p1 = { getProviderName: () => 'provider1', connect: jest.fn() };
const p2 = { getProviderName: () => 'provider2', connect: jest.fn() };
const e1 = { provider: p1 };
const e2 = { provider: p2 };
expect(
filterProviders([p1, p2], mockServices.rootConfig({ data: {} })),
).toEqual([p1, p2]);
filterProviders([e1, e2], mockServices.rootConfig({ data: {} })),
).toEqual([e1, e2]);
expect(
filterProviders(
[p1, p2],
[e1, e2],
mockServices.rootConfig({
data: {
catalog: {
@@ -153,11 +155,11 @@ describe('filterProviders', () => {
},
}),
),
).toEqual([p2]);
).toEqual([e2]);
expect(
filterProviders(
[p1, p2],
[e1, e2],
mockServices.rootConfig({
data: {
catalog: {
@@ -168,16 +170,18 @@ describe('filterProviders', () => {
},
}),
),
).toEqual([p1]);
).toEqual([e1]);
});
it('rejects invalid config', () => {
const p1 = { getProviderName: () => 'provider1', connect: jest.fn() };
const p2 = { getProviderName: () => 'provider2', connect: jest.fn() };
const e1 = { provider: p1 };
const e2 = { provider: p2 };
expect(() =>
filterProviders(
[p1, p2],
[e1, e2],
mockServices.rootConfig({
data: {
catalog: {
+9 -14
View File
@@ -24,17 +24,14 @@ import {
QueryEntitiesInitialRequest,
QueryEntitiesRequest,
} from '../catalog/types';
import {
CatalogProcessor,
EntityFilter,
EntityProvider,
} from '@backstage/plugin-catalog-node';
import { CatalogProcessor, EntityFilter } from '@backstage/plugin-catalog-node';
import {
Entity,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import type { EntityProviderEntry } from '../processing/connectEntityProviders';
export async function requireRequestBody(req: Request): Promise<unknown> {
const contentType = req.header('content-type');
@@ -225,13 +222,13 @@ export function filterAndSortProcessors(
* through the `catalog.providerOptions` config.
*/
export function filterProviders(
providers: EntityProvider[],
providers: EntityProviderEntry[],
config: Config,
): EntityProvider[] {
function getProviderOptions(provider: EntityProvider): Config | undefined {
): EntityProviderEntry[] {
function getProviderOptions(entry: EntityProviderEntry): Config | undefined {
const root = config.getOptionalConfig('catalog.providerOptions');
try {
return root?.getOptionalConfig(provider.getProviderName());
return root?.getOptionalConfig(entry.provider.getProviderName());
} catch {
// We silence errors specifically here, to cover for cases where the
// provider name contains special characters which makes the config
@@ -240,11 +237,9 @@ export function filterProviders(
}
}
function isProviderDisabled(provider: EntityProvider): boolean {
return (
getProviderOptions(provider)?.getOptionalBoolean('disabled') === true
);
function isProviderDisabled(entry: EntityProviderEntry): boolean {
return getProviderOptions(entry)?.getOptionalBoolean('disabled') === true;
}
return providers.filter(p => !isProviderDisabled(p));
return providers.filter(entry => !isProviderDisabled(entry));
}
@@ -317,7 +317,13 @@ class TestHarness {
providers.push(...options.additionalProviders);
}
await connectEntityProviders(providerDatabase, providers);
await connectEntityProviders(
providerDatabase,
providers.map(p => ({
provider: p,
context: { reportModuleStartupFailure: jest.fn() },
})),
);
return new TestHarness(
catalog,
@@ -525,6 +531,35 @@ describe('Catalog Backend Integration', () => {
});
});
it('should report module failures when provider connect throws', async () => {
const db = await databases.init('SQLITE_3');
await applyDatabaseMigrations(db);
const providerDatabase = new DefaultProviderDatabase({
database: db,
logger: mockServices.logger.mock(),
});
const error = new Error('NOPE');
const reportFailure = jest.fn();
const provider: EntityProvider = {
getProviderName: () => 'failing',
async connect() {
throw error;
},
};
await expect(
connectEntityProviders(providerDatabase, [
{
provider,
context: {
reportModuleStartupFailure: reportFailure,
},
},
]),
).resolves.toBeUndefined();
expect(reportFailure).toHaveBeenCalledWith({ error });
});
it('should orphan entities', async () => {
const generatedApis = ['api-1', 'api-2'];