refactor(backend-dynamic-feature-service): Improve alpha package support

Signed-off-by: David Festal <dfestal@redhat.com>
This commit is contained in:
David Festal
2024-10-15 13:06:42 +02:00
parent d5652801eb
commit 8593dfa13a
13 changed files with 290 additions and 61 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-dynamic-feature-service': patch
---
Improve the way alpha packages are supported when loading dynamic backend plugins.
The `ScannedPluginPackage` descriptor of dynamic backend plugins loaded from their alpha `package.json` now contain both the main package manifest and the alpha manifest. Previously it used to contain only the content of the alpha `package.json`, which is nearly empty.
This will make it easier to use or display metadata of loaded dynamic backend plugins, which is contained in the main manifest.
@@ -287,6 +287,8 @@ export type ScannedPluginManifest = BackstagePackageJson &
// @public (undocumented)
export interface ScannedPluginPackage {
// (undocumented)
alphaManifest?: BackstagePackageJson;
// (undocumented)
location: URL;
// (undocumented)
@@ -0,0 +1,5 @@
{
"name": "plugin-test-backend-alpha-dynamic__alpha",
"version": "0.0.0",
"main": "../dist/alpha.cjs.js"
}
@@ -0,0 +1,23 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var backendPluginApi = require('@backstage/backend-plugin-api');
const testAlphaPlugin = backendPluginApi.createBackendPlugin({
pluginId: "test-alpha",
register(env) {
env.registerInit({
deps: {
logger: backendPluginApi.coreServices.rootLogger,
},
async init({
logger,
}) {
logger.info("This plugin has been loaded from the alpha package.");
}
});
}
});
exports.default = testAlphaPlugin;
@@ -0,0 +1,3 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
@@ -0,0 +1,36 @@
{
"name": "plugin-test-backend-alpha-dynamic",
"version": "0.0.0",
"description": "A test dynamic backend module that exposes alpha API.",
"backstage": {
"role": "backend-plugin",
"pluginId": "test-alpha",
"pluginPackages": [
"plugin-test-backend-alpha"
]
},
"publishConfig": {
"access": "public"
},
"keywords": [
"backstage",
"dynamic"
],
"exports": {
".": {
"require": "./dist/index.cjs.js",
"default": "./dist/index.cjs.js"
},
"./alpha": {
"require": "./dist/alpha.cjs.js",
"default": "./dist/alpha.cjs.js"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs.js",
"files": [
"dist",
"alpha"
],
"bundleDependencies": true
}
@@ -29,8 +29,10 @@ import {
} from '@backstage/backend-plugin-api';
import { CommonJSModuleLoader } from '../loader/CommonJSModuleLoader';
import * as winston from 'winston';
import * as url from 'url';
import { MESSAGE } from 'triple-beam';
import { overridePackagePathResolution } from '@backstage/backend-plugin-api/testUtils';
import { ScannedPluginPackage } from '../scanner';
// these can get a bit slow in CI
jest.setTimeout(60_000);
@@ -67,6 +69,8 @@ class MockedTransport extends winston.transports.Console {
class DynamicPluginLister {
readonly loadedPlugins: DynamicPlugin[] = [];
getScannedPackage?: (plugin: DynamicPlugin) => ScannedPluginPackage;
feature(): BackendFeature {
// eslint-disable-next-line consistent-this
const that = this;
@@ -78,6 +82,8 @@ class DynamicPluginLister {
dynamicPlugins: dynamicPluginsServiceRef,
},
async init({ dynamicPlugins }) {
that.getScannedPackage = plugin =>
dynamicPlugins.getScannedPackage(plugin);
that.loadedPlugins.push(
...dynamicPlugins.plugins({ includeFailed: true }),
);
@@ -308,4 +314,70 @@ describe('dynamicPluginsFeatureLoader', () => {
},
});
});
it('should load a backend plugin from the alpha package first', async () => {
const dynamicPLuginsLister = new DynamicPluginLister();
const mockedTransport = new MockedTransport();
const dynamicPluginsRootForAlpha = resolvePath(
__dirname,
'__fixtures__/dynamic-plugins-root-for-alpha',
);
await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootForAlpha,
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: jestFreeTypescriptAwareModuleLoader,
transports: [mockedTransport],
format: winston.format.simple(),
}),
dynamicPLuginsLister.feature(),
],
});
expect(mockedTransport.logs).toContainEqual(
'info: This plugin has been loaded from the alpha package. {"service":"backstage"}',
);
const loadedPlugins = dynamicPLuginsLister.loadedPlugins;
expect(loadedPlugins).toMatchObject([
{
installer: {
kind: 'new',
},
name: 'plugin-test-backend-alpha-dynamic',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
]);
expect(
dynamicPLuginsLister.getScannedPackage?.(loadedPlugins[0]),
).toMatchObject({
location: url.pathToFileURL(
path.resolve(dynamicPluginsRootForAlpha, 'test-backend-alpha-dynamic'),
),
manifest: {
name: 'plugin-test-backend-alpha-dynamic',
version: '0.0.0',
description: 'A test dynamic backend module that exposes alpha API.',
backstage: {
role: 'backend-plugin',
pluginId: 'test-alpha',
pluginPackages: ['plugin-test-backend-alpha'],
},
keywords: ['backstage', 'dynamic'],
},
alphaManifest: {
name: 'plugin-test-backend-alpha-dynamic__alpha',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
},
});
});
});
@@ -44,7 +44,7 @@ import { PluginScanner } from '../scanner/plugin-scanner';
import { findPaths } from '@backstage/cli-common';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { PackageRole } from '@backstage/cli-node';
import { BackstagePackageJson, PackageRole } from '@backstage/cli-node';
describe('backend-dynamic-feature-service', () => {
const mockDir = createMockDirectory();
@@ -61,6 +61,13 @@ describe('backend-dynamic-feature-service', () => {
relativePath: string[];
content: string;
};
alpha?: {
packageManifest: BackstagePackageJson;
indexFile: {
relativePath: string[];
content: string;
};
};
expectedLogs?(location: URL): {
errors?: LogContent[];
warns?: LogContent[];
@@ -127,7 +134,7 @@ describe('backend-dynamic-feature-service', () => {
},
indexFile: {
relativePath: ['dist', 'index.cjs.js'],
content: `const alpha = { $$type: '@backstage/BackendFeature' }; exports["default"] = alpha;`,
content: `const feature = { $$type: '@backstage/BackendFeature' }; exports["default"] = feature;`,
},
expectedLogs(location) {
return {
@@ -158,6 +165,60 @@ describe('backend-dynamic-feature-service', () => {
);
},
},
{
name: 'should load the alpha variant of a backend plugin in priority',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
relativePath: ['dist', 'index.cjs.js'],
content: `throw 'should not take this one';`,
},
alpha: {
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
},
indexFile: {
relativePath: ['dist', 'alpha.cjs.js'],
content: `const alpha = { $$type: '@backstage/BackendFeature' }; exports["default"] = alpha;`,
},
},
expectedLogs(location) {
return {
infos: [
{
message: `loaded dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}/alpha'`,
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([
{
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
role: 'backend-plugin',
platform: 'node',
installer: {
kind: 'new',
},
},
]);
const installer: NewBackendPluginInstaller = (
plugins[0] as BackendDynamicPlugin
).installer as NewBackendPluginInstaller;
expect((installer.install() as BackendFeature).$$type).toEqual(
'@backstage/BackendFeature',
);
},
},
{
name: 'should successfully load a new backend plugin by the default BackendFeatureFactory',
packageManifest: {
@@ -580,11 +641,12 @@ describe('backend-dynamic-feature-service', () => {
const plugin: ScannedPluginPackage = {
location: url.pathToFileURL(mockDir.resolve(randomUUID())),
manifest: tc.packageManifest,
alphaManifest: tc.alpha?.packageManifest,
};
const mockedFiles = {
[path.join(url.fileURLToPath(plugin.location), 'package.json')]:
JSON.stringify(plugin),
JSON.stringify(tc.packageManifest),
};
if (tc.indexFile) {
mockedFiles[
@@ -594,6 +656,18 @@ describe('backend-dynamic-feature-service', () => {
)
] = tc.indexFile.content;
}
if (tc.alpha) {
mockedFiles[
path.join(url.fileURLToPath(plugin.location), 'alpha', 'package.json')
] = JSON.stringify(tc.alpha.packageManifest);
mockedFiles[
path.join(
url.fileURLToPath(plugin.location),
...tc.alpha.indexFile.relativePath,
)
] = tc.alpha.indexFile.content;
}
mockDir.setContent(mockedFiles);
const logger = new MockedLogger();
@@ -36,7 +36,6 @@ import {
} from '@backstage/backend-plugin-api';
import { PackageRole, PackageRoles } from '@backstage/cli-node';
import { findPaths } from '@backstage/cli-common';
import path from 'path';
import * as fs from 'fs';
import {
FeatureDiscoveryService,
@@ -79,13 +78,7 @@ export class DynamicPluginManager implements DynamicPluginProvider {
);
const dynamicPluginsPaths = scannedPlugins.map(p =>
fs.realpathSync(
path.dirname(
path.dirname(
path.resolve(url.fileURLToPath(p.location), p.manifest.main),
),
),
),
fs.realpathSync(url.fileURLToPath(p.location)),
);
await moduleLoader.bootstrap(backstageRoot, dynamicPluginsPaths);
@@ -164,8 +157,13 @@ export class DynamicPluginManager implements DynamicPluginProvider {
private async loadBackendPlugin(
plugin: ScannedPluginPackage,
): Promise<BackendDynamicPlugin> {
const usedPluginManifest =
plugin.alphaManifest?.main ?? plugin.manifest.main;
const usedPluginLocation = plugin.alphaManifest?.main
? `${plugin.location}/alpha`
: plugin.location;
const packagePath = url.fileURLToPath(
`${plugin.location}/${plugin.manifest.main}`,
`${usedPluginLocation}/${usedPluginManifest}`,
);
const dynamicPlugin: BackendDynamicPlugin = {
name: plugin.manifest.name,
@@ -194,12 +192,12 @@ export class DynamicPluginManager implements DynamicPluginProvider {
}
if (dynamicPlugin.installer) {
this.logger.info(
`loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`,
`loaded dynamic backend plugin '${plugin.manifest.name}' from '${usedPluginLocation}'`,
);
} else {
dynamicPlugin.failure = `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`;
this.logger.error(
`dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': ${dynamicPlugin.failure}`,
`dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${usedPluginLocation}': ${dynamicPlugin.failure}`,
);
}
return dynamicPlugin;
@@ -210,7 +208,7 @@ export class DynamicPluginManager implements DynamicPluginProvider {
: new Error(error);
dynamicPlugin.failure = `${typedError.name}: ${typedError.message}`;
this.logger.error(
`an error occurred while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`,
`an error occurred while loading dynamic backend plugin '${plugin.manifest.name}' from '${usedPluginLocation}'`,
typedError,
);
return dynamicPlugin;
@@ -430,16 +430,19 @@ Please add '${mockDir.resolve(
expectedPluginPackages: [
{
location: url.pathToFileURL(
mockDir.resolve(
'backstageRoot/dist-dynamic/test-backend-plugin/alpha',
),
mockDir.resolve('backstageRoot/dist-dynamic/test-backend-plugin'),
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
},
alphaManifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
},
},
],
},
@@ -21,7 +21,6 @@ import * as chokidar from 'chokidar';
import * as path from 'path';
import * as url from 'url';
import debounce from 'lodash/debounce';
import { PackagePlatform, PackageRoles } from '@backstage/cli-node';
import { LoggerService } from '@backstage/backend-plugin-api';
export interface DynamicPluginScannerOptions {
@@ -37,6 +36,15 @@ export interface ScanRootResponse {
export const configKey = 'dynamicPlugins';
class WrappedError extends Error {
wrapped: Error;
constructor(message: string, wrapped: Error) {
super(message);
this.wrapped = wrapped;
}
}
export class PluginScanner {
private _rootDirectory?: string;
private configUnsubscribe?: () => void;
@@ -161,52 +169,25 @@ export class PluginScanner {
}
let scannedPlugin: ScannedPluginPackage;
let platform: PackagePlatform;
try {
scannedPlugin = await this.scanDir(pluginHome);
if (!scannedPlugin.manifest.main) {
throw new Error("field 'main' not found in 'package.json'");
}
if (scannedPlugin.manifest.backstage?.role) {
platform = PackageRoles.getRoleInfo(
scannedPlugin.manifest.backstage.role,
).platform;
} else {
if (!scannedPlugin.manifest.backstage?.role) {
throw new Error("field 'backstage.role' not found in 'package.json'");
}
} catch (e) {
this.logger.error(
`failed to load dynamic plugin manifest from '${pluginHome}'`,
e,
);
if (e instanceof WrappedError) {
this.logger.error(e.message, e.wrapped);
} else {
this.logger.error(
`failed to load dynamic plugin manifest from '${pluginHome}'`,
e,
);
}
continue;
}
if (platform === 'node') {
if (this.preferAlpha) {
const pluginHomeAlpha = path.resolve(pluginHome, 'alpha');
if (existsSync(pluginHomeAlpha)) {
if ((await fs.lstat(pluginHomeAlpha)).isDirectory()) {
const backstage = scannedPlugin.manifest.backstage;
try {
scannedPlugin = await this.scanDir(pluginHomeAlpha);
} catch (e) {
this.logger.error(
`failed to load dynamic plugin manifest from '${pluginHomeAlpha}'`,
e,
);
continue;
}
scannedPlugin.manifest.backstage = backstage;
} else {
this.logger.warn(
`skipping '${pluginHomeAlpha}' since it is not a directory`,
);
}
}
}
}
scannedPlugins.push(scannedPlugin);
}
return { packages: scannedPlugins };
@@ -216,10 +197,37 @@ export class PluginScanner {
const manifestFile = path.resolve(pluginHome, 'package.json');
const content = await fs.readFile(manifestFile);
const manifest: ScannedPluginManifest = JSON.parse(content.toString());
return {
const scannedPluginPackage: ScannedPluginPackage = {
location: url.pathToFileURL(pluginHome),
manifest: manifest,
};
if (this.preferAlpha) {
const pluginHomeAlpha = path.resolve(pluginHome, 'alpha');
if (existsSync(pluginHomeAlpha)) {
if ((await fs.lstat(pluginHomeAlpha)).isDirectory()) {
try {
const alphaContent = await fs.readFile(
path.resolve(pluginHomeAlpha, 'package.json'),
);
scannedPluginPackage.alphaManifest = JSON.parse(
alphaContent.toString(),
);
} catch (e) {
throw new WrappedError(
`failed to load dynamic plugin manifest from '${pluginHome}/alpha'`,
e,
);
}
} else {
this.logger.warn(
`skipping '${pluginHomeAlpha}' since it is not a directory`,
);
}
}
}
return scannedPluginPackage;
}
async trackChanges(): Promise<void> {
@@ -22,6 +22,7 @@ import { BackstagePackageJson, PackageRole } from '@backstage/cli-node';
export interface ScannedPluginPackage {
location: URL;
manifest: ScannedPluginManifest;
alphaManifest?: BackstagePackageJson;
}
/**
@@ -161,10 +161,7 @@ async function gatherDynamicPluginsSchemas(
let schemaLocation = schemaLocator(pluginPackage);
if (!path.isAbsolute(schemaLocation)) {
let pluginLocation = url.fileURLToPath(pluginPackage.location);
if (path.basename(pluginLocation) === 'alpha') {
pluginLocation = path.dirname(pluginLocation);
}
const pluginLocation = url.fileURLToPath(pluginPackage.location);
schemaLocation = path.resolve(pluginLocation, schemaLocation);
}