diff --git a/.changeset/funny-ligers-matter.md b/.changeset/funny-ligers-matter.md new file mode 100644 index 0000000000..0cec3eb333 --- /dev/null +++ b/.changeset/funny-ligers-matter.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-app-api': patch +--- + +Adds include and exclude configuration to feature discovery of backend packages +Adds alpha modules to feature discovery diff --git a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts index 5d4b1fc551..f054c1a365 100644 --- a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts +++ b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.test.ts @@ -34,6 +34,7 @@ describe('featureDiscoveryServiceFactory', () => { dependencies: { 'detected-plugin': '0.0.0', 'detected-module': '0.0.0', + 'detected-plugin-with-alpha': '0.0.0', }, }), }, @@ -65,7 +66,7 @@ describe('featureDiscoveryServiceFactory', () => { name: 'detected-module', main: 'index.js', backstage: { - role: 'backend-module', + role: 'backend-plugin-module', }, }), 'index.js': ` @@ -84,6 +85,39 @@ describe('featureDiscoveryServiceFactory', () => { }); `, }, + [resolvePath(rootDir, 'node_modules/detected-plugin-with-alpha')]: { + 'package.json': JSON.stringify({ + name: 'detected-plugin-with-alpha', + main: 'index.js', + exports: { + '.': { + default: 'index.js', + }, + './alpha': { + default: 'alpha.js', + }, + './package.json': './package.json', + }, + backstage: { + role: 'backend-plugin', + }, + }), + 'index.js': `exports.detectedPlugin = undefined;`, + 'alpha.js': ` + const { createBackendPlugin, coreServices } = require('@backstage/backend-plugin-api'); + exports.detectedPluginAlpha = createBackendPlugin({ + pluginId: 'detected-alpha', + register(env) { + env.registerInit({ + deps: { identity: coreServices.identity }, + async init({ identity }) { + identity.getIdentity('detected-plugin-with-alpha'); + }, + }); + }, + }); + `, + }, }); }); @@ -91,7 +125,7 @@ describe('featureDiscoveryServiceFactory', () => { mockFs.restore(); }); - it('should detect plugin and module packages', async () => { + it('should detect plugin and module packages when "all" is specified', async () => { const fn = jest.fn().mockResolvedValue({}); await startTestBackend({ @@ -110,5 +144,191 @@ describe('featureDiscoveryServiceFactory', () => { expect(fn).toHaveBeenCalledWith('detected-plugin'); expect(fn).toHaveBeenCalledWith('detected-module'); + expect(fn).toHaveBeenCalledWith('detected-plugin-with-alpha'); + }); + + it('detects only the packages that are listed as included', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { + backend: { + packages: { + include: ['detected-plugin', 'detected-plugin-with-alpha'], + }, + }, + }, + }), + ], + }); + + expect(fn).toHaveBeenCalledWith('detected-plugin'); + expect(fn).toHaveBeenCalledWith('detected-plugin-with-alpha'); + expect(fn).not.toHaveBeenCalledWith('detected-module'); + }); + + it('does not detect packages when included is an empty list', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { + backend: { + packages: { + include: [], + }, + }, + }, + }), + ], + }); + + expect(fn).not.toHaveBeenCalledWith('detected-plugin'); + expect(fn).not.toHaveBeenCalledWith('detected-plugin-with-alpha'); + expect(fn).not.toHaveBeenCalledWith('detected-module'); + }); + + it('does not detect an excluded packages', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { + backend: { + packages: { + exclude: ['detected-plugin', 'detected-module'], + }, + }, + }, + }), + ], + }); + + expect(fn).not.toHaveBeenCalledWith('detected-plugin'); + expect(fn).not.toHaveBeenCalledWith('detected-module'); + expect(fn).toHaveBeenCalledWith('detected-plugin-with-alpha'); + }); + + it('does not excluded packages when it is an empty list', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { + backend: { + packages: { + exclude: [], + }, + }, + }, + }), + ], + }); + + expect(fn).toHaveBeenCalledWith('detected-plugin'); + expect(fn).toHaveBeenCalledWith('detected-module'); + expect(fn).toHaveBeenCalledWith('detected-plugin-with-alpha'); + }); + + it('does not detect packages that are included and excluded', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { + backend: { + packages: { + include: [ + 'detected-plugin', + 'detected-module', + 'detected-plugin-with-alpha', + ], + exclude: ['detected-plugin'], + }, + }, + }, + }), + ], + }); + + expect(fn).not.toHaveBeenCalledWith('detected-plugin'); + expect(fn).toHaveBeenCalledWith('detected-module'); + expect(fn).toHaveBeenCalledWith('detected-plugin-with-alpha'); + }); + + it('does not detect any packages when "packages" is empty', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { backend: { packages: {} } }, + }), + ], + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('does not detect any packages when "packages" is not present', async () => { + const fn = jest.fn().mockResolvedValue({}); + + await startTestBackend({ + features: [ + createServiceFactory({ + service: coreServices.identity, + deps: {}, + factory: () => ({ getIdentity: fn }), + }), + featureDiscoveryServiceFactory(), + mockServices.rootConfig.factory({ + data: { backend: {} }, + }), + ], + }); + + expect(fn).not.toHaveBeenCalled(); }); }); diff --git a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts index f9ee7edc5f..07d5c4627d 100644 --- a/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts +++ b/packages/backend-app-api/src/alpha/featureDiscoveryServiceFactory.ts @@ -17,6 +17,7 @@ import { BackendFeature, RootConfigService, + RootLoggerService, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; @@ -28,7 +29,7 @@ import { resolve as resolvePath, dirname } from 'path'; import fs from 'fs-extra'; import { BackstagePackageJson } from '@backstage/cli-node'; -const LOADED_PACKAGE_ROLES = ['backend-plugin', 'backend-module']; +const LOADED_PACKAGE_ROLES = ['backend-plugin', 'backend-plugin-module']; /** @internal */ async function findClosestPackageDir( @@ -58,10 +59,38 @@ async function findClosestPackageDir( /** @internal */ class PackageDiscoveryService implements FeatureDiscoveryService { - constructor(private readonly config: RootConfigService) {} + constructor( + private readonly config: RootConfigService, + private readonly logger: RootLoggerService, + ) {} + + getDependencyNames(path: string) { + const { dependencies } = require(path) as BackstagePackageJson; + const packagesConfig = this.config.getOptional('backend.packages'); + + const dependencyNames = Object.keys(dependencies || {}); + + if (packagesConfig === 'all') { + return dependencyNames; + } + + const includedPackagesConfig = this.config.getOptionalStringArray( + 'backend.packages.include', + ); + + const includedPackages = includedPackagesConfig + ? new Set(includedPackagesConfig) + : dependencyNames; + const excludedPackagesSet = new Set( + this.config.getOptionalStringArray('backend.packages.exclude'), + ); + + return [...includedPackages].filter(name => !excludedPackagesSet.has(name)); + } async getBackendFeatures(): Promise<{ features: Array }> { - if (this.config.getOptionalString('backend.packages') !== 'all') { + const packagesConfig = this.config.getOptional('backend.packages'); + if (!packagesConfig || Object.keys(packagesConfig).length === 0) { return { features: [] }; } @@ -69,11 +98,9 @@ class PackageDiscoveryService implements FeatureDiscoveryService { if (!packageDir) { throw new Error('Package discovery failed to find package.json'); } - const { dependencies } = require(resolvePath( - packageDir, - 'package.json', - )) as BackstagePackageJson; - const dependencyNames = Object.keys(dependencies || {}); + const dependencyNames = this.getDependencyNames( + resolvePath(packageDir, 'package.json'), + ); const features: BackendFeature[] = []; @@ -84,13 +111,33 @@ class PackageDiscoveryService implements FeatureDiscoveryService { if (!LOADED_PACKAGE_ROLES.includes(depPkg?.backstage?.role ?? '')) { continue; } - const depModule = require(require.resolve(name, { paths: [packageDir] })); - for (const exportValue of Object.values(depModule)) { - if (isBackendFeature(exportValue)) { - features.push(exportValue); - } - if (isBackendFeatureFactory(exportValue)) { - features.push(exportValue()); + + const exportedModulePaths = [ + require.resolve(name, { + paths: [packageDir], + }), + ]; + + // Find modules exported as alpha + try { + exportedModulePaths.push( + require.resolve(`${name}/alpha`, { paths: [packageDir] }), + ); + } catch { + /* ignore */ + } + + for (const modulePath of exportedModulePaths) { + const module = require(modulePath); + for (const [exportName, exportValue] of Object.entries(module)) { + if (isBackendFeature(exportValue)) { + this.logger.info(`Detected: ${name}#${exportName}`); + features.push(exportValue); + } + if (isBackendFeatureFactory(exportValue)) { + this.logger.info(`Detected: ${name}#${exportName}`); + features.push(exportValue()); + } } } } @@ -104,9 +151,10 @@ export const featureDiscoveryServiceFactory = createServiceFactory({ service: featureDiscoveryServiceRef, deps: { config: coreServices.rootConfig, + logger: coreServices.rootLogger, }, - factory({ config }) { - return new PackageDiscoveryService(config); + factory({ config, logger }) { + return new PackageDiscoveryService(config, logger); }, });