diff --git a/.changeset/chatty-cars-kick.md b/.changeset/chatty-cars-kick.md new file mode 100644 index 0000000000..fcda65d0f7 --- /dev/null +++ b/.changeset/chatty-cars-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': patch +--- + +Adds experimental `catalogServiceRef` for obtaining a `CatalogClient` in the new backend system. diff --git a/.changeset/purple-jeans-bathe.md b/.changeset/purple-jeans-bathe.md new file mode 100644 index 0000000000..39706db2c4 --- /dev/null +++ b/.changeset/purple-jeans-bathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Updated `ServiceRegistry` to not initialize factories more than once. diff --git a/.changeset/stupid-pigs-appear.md b/.changeset/stupid-pigs-appear.md new file mode 100644 index 0000000000..bd34f984d1 --- /dev/null +++ b/.changeset/stupid-pigs-appear.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added alpha `scaffolderPlugin` to be used with experimental backend system. diff --git a/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts b/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts index acdd2a4c41..d5b688c0de 100644 --- a/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts +++ b/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts @@ -17,6 +17,7 @@ import { createServiceRef, createServiceFactory, + ServiceRef, } from '@backstage/backend-plugin-api'; import { ServiceRegistry } from './ServiceRegistry'; @@ -170,4 +171,53 @@ describe('ServiceRegistry', () => { expect(await factoryB('catalog')).toBe(await factoryB('catalog')); expect(await factoryA('catalog')).not.toBe(await factoryB('catalog')); }); + + it('should only call each default factory loader once', async () => { + const factoryLoader = jest.fn(async (service: ServiceRef) => + createServiceFactory({ + service, + deps: {}, + factory: async () => async () => {}, + }), + ); + const ref = createServiceRef({ + id: '1', + defaultFactory: factoryLoader, + }); + + const registry = new ServiceRegistry([]); + const factory = registry.get(ref)!; + await Promise.all([ + expect(factory('catalog')).resolves.toBeUndefined(), + expect(factory('catalog')).resolves.toBeUndefined(), + ]); + expect(factoryLoader).toHaveBeenCalledTimes(1); + }); + + it('should not call factory functions more than once', async () => { + const innerFactory = jest.fn(async (pluginId: string) => { + return { x: 1, pluginId }; + }); + const factory = jest.fn(async () => innerFactory); + const myFactory = createServiceFactory({ + service: ref1, + deps: {}, + factory, + }); + + const registry = new ServiceRegistry([myFactory]); + + await Promise.all([ + registry.get(ref1)!('catalog')!, + registry.get(ref1)!('catalog')!, + registry.get(ref1)!('catalog')!, + registry.get(ref1)!('scaffolder')!, + registry.get(ref1)!('scaffolder')!, + ]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(innerFactory).toHaveBeenCalledTimes(2); + expect(innerFactory).toHaveBeenCalledWith('catalog'); + expect(innerFactory).toHaveBeenCalledWith('scaffolder'); + }); }); diff --git a/packages/backend-app-api/src/wiring/ServiceRegistry.ts b/packages/backend-app-api/src/wiring/ServiceRegistry.ts index b471cd67ca..9df092db42 100644 --- a/packages/backend-app-api/src/wiring/ServiceRegistry.ts +++ b/packages/backend-app-api/src/wiring/ServiceRegistry.ts @@ -21,8 +21,14 @@ import { export class ServiceRegistry { readonly #providedFactories: Map; - readonly #loadedDefaultFactories: Map; - readonly #implementations: Map>; + readonly #loadedDefaultFactories: Map>; + readonly #implementations: Map< + ServiceFactory, + { + factoryFunc: Promise>; + byPlugin: Map>; + } + >; constructor(factories: ServiceFactory[]) { this.#providedFactories = new Map(factories.map(f => [f.service.id, f])); @@ -41,35 +47,38 @@ export class ServiceRegistry { if (!factory) { let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!); if (!loadedFactory) { - loadedFactory = (await defaultFactory!(ref)) as ServiceFactory; + loadedFactory = defaultFactory!(ref) as Promise; this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory); } - factory = loadedFactory; + // NOTE: This await is safe as long as #providedFactories is not mutated. + factory = await loadedFactory; } - let implementations = this.#implementations.get(factory); - if (implementations) { - if (implementations.has(pluginId)) { - return implementations.get(pluginId) as T; - } - } else { - implementations = new Map(); - this.#implementations.set(factory, implementations); + let implementation = this.#implementations.get(factory); + if (!implementation) { + const factoryDeps = Object.fromEntries( + Object.entries(factory.deps).map(([name, serviceRef]) => [ + name, + this.get(serviceRef)!, // TODO: throw + ]), + ); + + implementation = { + factoryFunc: factory.factory(factoryDeps), + byPlugin: new Map(), + }; + + this.#implementations.set(factory, implementation); } - const factoryDeps = Object.fromEntries( - Object.entries(factory.deps).map(([name, serviceRef]) => [ - name, - this.get(serviceRef)!, // TODO: throw - ]), - ); + let result = implementation.byPlugin.get(pluginId) as Promise; + if (!result) { + result = implementation.factoryFunc.then(func => func(pluginId)); - const factoryFunc = await factory.factory(factoryDeps); - const implementation = await factoryFunc(pluginId); + implementation.byPlugin.set(pluginId, result); + } - implementations.set(pluginId, implementation); - - return implementation as T; + return result; }; } } diff --git a/plugins/catalog-node/api-report.md b/plugins/catalog-node/api-report.md index 26617910b9..7d12c2e065 100644 --- a/plugins/catalog-node/api-report.md +++ b/plugins/catalog-node/api-report.md @@ -5,10 +5,12 @@ ```ts /// +import { CatalogApi } from '@backstage/catalog-client'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { JsonValue } from '@backstage/types'; +import { ServiceRef } from '@backstage/backend-plugin-api'; // @alpha (undocumented) export interface CatalogProcessingExtensionPoint { @@ -106,6 +108,9 @@ export type CatalogProcessorResult = | CatalogProcessorErrorResult | CatalogProcessorRefreshKeysResult; +// @alpha +export const catalogServiceRef: ServiceRef; + // @public export type DeferredEntity = { entity: Entity; diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index 10d1d9d2dd..1e4095b7d4 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -26,13 +26,15 @@ }, "dependencies": { "@backstage/backend-plugin-api": "^0.1.2-next.0", + "@backstage/catalog-client": "^1.0.5-next.0", "@backstage/catalog-model": "^1.1.0", "@backstage/errors": "1.1.0", "@backstage/types": "^1.0.0" }, "devDependencies": { "@backstage/backend-common": "^0.15.1-next.1", - "@backstage/cli": "^0.19.0-next.1" + "@backstage/cli": "^0.19.0-next.1", + "@backstage/backend-test-utils": "^0.1.28-next.1" }, "files": [ "dist", diff --git a/plugins/catalog-node/src/catalogService.test.ts b/plugins/catalog-node/src/catalogService.test.ts new file mode 100644 index 0000000000..6e9426d48b --- /dev/null +++ b/plugins/catalog-node/src/catalogService.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2022 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 { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + createBackendModule, + createServiceFactory, + discoveryServiceRef, +} from '@backstage/backend-plugin-api'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { CatalogClient } from '@backstage/catalog-client'; +import { catalogServiceRef } from './catalogService'; + +describe('catalogServiceRef', () => { + it('should return a catalogClient', async () => { + expect.assertions(1); + + const mockDiscoveryFactory = createServiceFactory({ + service: discoveryServiceRef, + deps: {}, + factory: async ({}) => { + return async () => jest.fn() as unknown as PluginEndpointDiscovery; + }, + }); + + const testModule = createBackendModule({ + moduleId: 'test.module', + pluginId: 'test', + register(env) { + env.registerInit({ + deps: { + catalog: catalogServiceRef, + }, + async init({ catalog }) { + expect(catalog).toBeInstanceOf(CatalogClient); + }, + }); + }, + }); + + await startTestBackend({ + services: [mockDiscoveryFactory], + features: [testModule({})], + }); + }); +}); diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts new file mode 100644 index 0000000000..ddfff75462 --- /dev/null +++ b/plugins/catalog-node/src/catalogService.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 { + createServiceFactory, + createServiceRef, + discoveryServiceRef, +} from '@backstage/backend-plugin-api'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; + +/** + * The catalogService provides the catalog API. + * @alpha + */ +export const catalogServiceRef = createServiceRef({ + id: 'catalog-client', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + discoveryFactory: discoveryServiceRef, + }, + factory: async ({ discoveryFactory }) => { + const discoveryApi = await discoveryFactory('root'); + const catalogClient = new CatalogClient({ discoveryApi }); + return async _pluginId => { + return catalogClient; + }; + }, + }), +}); diff --git a/plugins/catalog-node/src/index.ts b/plugins/catalog-node/src/index.ts index 02ae325a9a..6393c55f31 100644 --- a/plugins/catalog-node/src/index.ts +++ b/plugins/catalog-node/src/index.ts @@ -22,5 +22,6 @@ export type { CatalogProcessingExtensionPoint } from './extensions'; export { catalogProcessingExtensionPoint } from './extensions'; +export { catalogServiceRef } from './catalogService'; export * from './api'; export * from './processing'; diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index da8782667e..066b201918 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -569,6 +569,19 @@ export class ScaffolderEntitiesProcessor implements CatalogProcessor { validateEntityKind(entity: Entity): Promise; } +// @alpha +export const scaffolderPlugin: ( + options: ScaffolderPluginOptions, +) => BackendFeature; + +// @alpha +export type ScaffolderPluginOptions = { + actions?: TemplateAction[]; + taskWorkers?: number; + taskBroker?: TaskBroker; + additionalTemplateFilters?: Record; +}; + // @public export type SerializedTask = { id: string; diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts new file mode 100644 index 0000000000..b884802a1f --- /dev/null +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2022 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 { + configServiceRef, + createBackendPlugin, + databaseServiceRef, + loggerServiceRef, + loggerToWinstonLogger, + permissionsServiceRef, + urlReaderServiceRef, + httpRouterServiceRef, + createExtensionPoint, +} from '@backstage/backend-plugin-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { TemplateFilter } from './lib'; +import { createBuiltinActions, TaskBroker, TemplateAction } from './scaffolder'; +import { createRouter } from './service/router'; + +/** + * Catalog plugin options + * @alpha + */ +export type ScaffolderPluginOptions = { + actions?: TemplateAction[]; + taskWorkers?: number; + taskBroker?: TaskBroker; + additionalTemplateFilters?: Record; +}; + +/** + * @alpha + * TODO: MOVE to scaffolder-node. + */ +interface ScaffolderActionsExtensionPoint { + addActions(...actions: TemplateAction[]): void; +} + +class ScaffolderActionsExtensionPointImpl + implements ScaffolderActionsExtensionPoint +{ + #actions = new Array>(); + addActions(...actions: TemplateAction[]): void { + this.#actions.push(...actions); + } + get actions() { + return this.#actions; + } +} + +/** + * @alpha + * TODO: MOVE to scaffolder-node. + */ +export const scaffolderActionsExtensionPoint = + createExtensionPoint({ + id: 'scaffolder.actions', + }); + +/** + * Catalog plugin + * @alpha + */ +export const scaffolderPlugin = createBackendPlugin({ + id: 'scaffolder', + register(env, options: ScaffolderPluginOptions) { + const actionsExtensions = new ScaffolderActionsExtensionPointImpl(); + env.registerExtensionPoint( + scaffolderActionsExtensionPoint, + actionsExtensions, + ); + + env.registerInit({ + deps: { + logger: loggerServiceRef, + config: configServiceRef, + reader: urlReaderServiceRef, + permissions: permissionsServiceRef, + database: databaseServiceRef, + httpRouter: httpRouterServiceRef, + catalogClient: catalogServiceRef, + }, + async init({ + logger, + config, + reader, + database, + httpRouter, + catalogClient, + }) { + const { additionalTemplateFilters, taskBroker, taskWorkers } = options; + const log = loggerToWinstonLogger(logger); + + const actions = options.actions || [ + ...actionsExtensions.actions, + ...createBuiltinActions({ + integrations: ScmIntegrations.fromConfig(config), + catalogClient, + reader, + config, + additionalTemplateFilters, + }), + ]; + + const actionIds = actions.map(action => action.id).join(', '); + log.info( + `Starting scaffolder with the following actions enabled ${actionIds}`, + ); + + const router = await createRouter({ + logger: log, + config, + database, + catalogClient, + reader, + actions, + taskBroker, + taskWorkers, + additionalTemplateFilters, + }); + httpRouter.use(router); + }, + }); + }, +}); diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index a1032449d5..985ada405e 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -25,3 +25,5 @@ export * from './service/router'; export * from './lib'; export * from './processor'; export * from './extension'; +export { scaffolderPlugin } from './ScaffolderPlugin'; +export type { ScaffolderPluginOptions } from './ScaffolderPlugin';