Merge pull request #18862 from davidfestal/new-backend-plugin-manager

New experimental backend plugin manager package
This commit is contained in:
Patrik Oldsberg
2023-08-31 17:20:34 +02:00
committed by GitHub
20 changed files with 3016 additions and 0 deletions
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+52
View File
@@ -0,0 +1,52 @@
# @backstage/backend-plugin-manager
This package adds experimental support for **dynamic backend plugins**, according to the content of the proposal in [RFC 18390](https://github.com/backstage/backstage/issues/18390)
## Status
**This package is EXPERIMENTAL, and is subject to change according to the discussions and conclusions that happen around the RFC mentioned above.**
## Testing the backend dynamic plugins feature
In order to test the dynamic backend plugins feature provided by this package, example applications, as well as example dynamic plugins have been provided in provided in the [showcase repository](https://github.com/janus-idp/dynamic-backend-plugins-showcase), and instructions are provided in the [related Readme](https://github.com/janus-idp/dynamic-backend-plugins-showcase#readme).
## How it works
The backend plugin manager is a service that scans a configured root directory (`dynamicPlugins.rootDirectory` in the app config) for dynamic plugin packages, and loads them dynamically.
In the `backend-next` application, it can be enabled by adding the `backend-plugin-manager` as a dependency in the `package.json` and the following lines in the `src/index.ts` file:
```ts
const backend = createBackend();
+
+ backend.add(dynamicPluginsFeatureDiscoveryServiceFactory()) // overridden version of the FeatureDiscoveryService which provides features loaded by dynamic plugins
+ backend.add(dynamicPluginsServiceFactory())
+
```
### About the expected dynamic plugin structure
Due to some limitations of the current backstage codebase, the plugins need to be completed and repackaged to by used as dynamic plugins:
1. they must provide a named entry point (`dynamicPluginInstaller`) of a specific type (`BackendDynamicPluginInstaller`), as can be found in the `src/dynamic` sub-folder of each dynamic plugin example provided in the [showcase repository](https://github.com/janus-idp/dynamic-backend-plugins-showcase).
2. they would have a modified `package.json` file in which dependencies are updated to share `@backstage` dependencies with the main application.
3. they may embed some dependency whose code is then merged with the plugin code.
Points 2 and 3 can be done by the `export-dynamic-plugin` CLI command used to perform the repackaging
### About the `export-dynamic-plugin` command
The `export-dynamic-plugin` CLI command, used the dynamic plugin examples provided in the [showcase repository](https://github.com/janus-idp/dynamic-backend-plugins-showcase), is part of a `@backstage/cli` fork (`@dfatwork-pkgs/backstage-cli@0.22.9-next.6`), and can be used to help packaging the dynamic plugins according to the constraints mentioned above, in order to allow straightforward testing of the dynamic plugins feature.
However the `backend-plugin-manager` experimental package does **not** depend on the use of this additional CLI command, and in future steps of this backend dynamic plugin work, the use of such a dedicated command might not even be necessary.
### About the support of the legacy backend system
The backend dynamic plugins feature clearly targets the new backend system.
However some level of compatibility is provided with current backstage codebase, which still uses the legacy backend system, in order to allow testing and exploring dynamic backend plugin support on the widest range of contexts and installations.
However, this is temporary and will be removed once the next backend is ready to be used and has completely replaced the old one.
This is why the API related to the old backend is already marked as deprecated.
### Future work
The current implementation of the backend plugin manager is a first step towards the final implementation of the dynamic backend plugins feature, which will be completed / simplified in future steps, as the status of the backstage codebase allows it.
@@ -0,0 +1,199 @@
## API Report File for "@backstage/backend-plugin-manager"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BackstagePackageJson } from '@backstage/cli-node';
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { EventBroker } from '@backstage/plugin-events-node';
import { EventsBackend } from '@backstage/plugin-events-backend';
import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha';
import { HttpPostIngressOptions } from '@backstage/plugin-events-node';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { IndexBuilder } from '@backstage/plugin-search-backend-node';
import { Logger } from 'winston';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PackagePlatform } from '@backstage/cli-node';
import { PackageRole } from '@backstage/cli-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { PermissionPolicy } from '@backstage/plugin-permission-node';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { Router } from 'express';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
import { TaskRunner } from '@backstage/backend-tasks';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { TokenManager } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export interface BackendDynamicPlugin extends BaseDynamicPlugin {
// (undocumented)
installer: BackendDynamicPluginInstaller;
// (undocumented)
platform: 'node';
}
// @public (undocumented)
export type BackendDynamicPluginInstaller =
| LegacyBackendPluginInstaller
| NewBackendPluginInstaller;
// @public (undocumented)
export interface BackendPluginProvider {
// (undocumented)
backendPlugins(): BackendDynamicPlugin[];
}
// @public (undocumented)
export interface BaseDynamicPlugin {
// (undocumented)
name: string;
// (undocumented)
platform: PackagePlatform;
// (undocumented)
role: PackageRole;
// (undocumented)
version: string;
}
// @public (undocumented)
export type DynamicPlugin = FrontendDynamicPlugin | BackendDynamicPlugin;
// @public (undocumented)
export interface DynamicPluginsFactoryOptions {
// (undocumented)
moduleLoader?(logger: LoggerService): ModuleLoader;
}
// @public (undocumented)
export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory<
FeatureDiscoveryService,
'root'
>;
// @public (undocumented)
export const dynamicPluginsServiceFactory: (
options?: DynamicPluginsFactoryOptions | undefined,
) => ServiceFactory<BackendPluginProvider, 'root'>;
// @public (undocumented)
export const dynamicPluginsServiceRef: ServiceRef<
BackendPluginProvider,
'root'
>;
// @public (undocumented)
export interface FrontendDynamicPlugin extends BaseDynamicPlugin {
// (undocumented)
platform: 'web';
}
// @public (undocumented)
export function isBackendDynamicPluginInstaller(
obj: any,
): obj is BackendDynamicPluginInstaller;
// @public @deprecated (undocumented)
export interface LegacyBackendPluginInstaller {
// (undocumented)
catalog?(builder: CatalogBuilder, env: LegacyPluginEnvironment): void;
// (undocumented)
events?(
eventsBackend: EventsBackend,
env: LegacyPluginEnvironment,
): HttpPostIngressOptions[];
// (undocumented)
kind: 'legacy';
// (undocumented)
permissions?: {
policy?: PermissionPolicy;
};
// (undocumented)
router?: {
pluginID: string;
createPlugin(env: LegacyPluginEnvironment): Promise<Router>;
};
// (undocumented)
scaffolder?(env: LegacyPluginEnvironment): TemplateAction<any>[];
// (undocumented)
search?(
indexBuilder: IndexBuilder,
schedule: TaskRunner,
env: LegacyPluginEnvironment,
): void;
}
// @public @deprecated (undocumented)
export type LegacyPluginEnvironment = {
logger: Logger;
cache: PluginCacheManager;
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
permissions: PermissionEvaluator;
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
pluginProvider: BackendPluginProvider;
};
// @public (undocumented)
export interface ModuleLoader {
// (undocumented)
bootstrap(backstageRoot: string, dynamicPluginPaths: string[]): Promise<void>;
// (undocumented)
load(id: string): Promise<any>;
}
// @public (undocumented)
export interface NewBackendPluginInstaller {
// (undocumented)
install(): BackendFeature | BackendFeature[];
// (undocumented)
kind: 'new';
}
// @public (undocumented)
export class PluginManager implements BackendPluginProvider {
// (undocumented)
addBackendPlugin(plugin: BackendDynamicPlugin): void;
// (undocumented)
get availablePackages(): ScannedPluginPackage[];
// (undocumented)
backendPlugins(): BackendDynamicPlugin[];
// (undocumented)
static fromConfig(
config: Config,
logger: LoggerService,
preferAlpha?: boolean,
mooduleLoader?: ModuleLoader,
): Promise<PluginManager>;
// (undocumented)
readonly plugins: DynamicPlugin[];
}
// @public (undocumented)
export type ScannedPluginManifest = BackstagePackageJson &
Required<Pick<BackstagePackageJson, 'main'>> &
Required<Pick<BackstagePackageJson, 'backstage'>> & {
backstage: Required<BackstagePackageJson['backstage']>;
};
// @public (undocumented)
export interface ScannedPluginPackage {
// (undocumented)
location: URL;
// (undocumented)
manifest: ScannedPluginManifest;
}
// (No @packageDocumentation comment for this package)
```
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright 2023 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.
*/
export interface Config {
dynamicPlugins?: {
/**
* The local path, relative to the backstage root, that contains dynamic plugins.
*/
rootDirectory: string;
};
}
@@ -0,0 +1,64 @@
{
"name": "@backstage/backend-plugin-manager",
"description": "Backstage plugin management backend",
"version": "0.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "node-library"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean",
"start": "backstage-cli package start"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/cli-node": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-backend": "workspace:^",
"@backstage/plugin-events-backend": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/plugin-search-backend-node": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"chokidar": "^3.5.3",
"express": "^4.17.1",
"lodash": "^4.17.21",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config-loader": "workspace:^",
"mock-fs": "^5.2.0",
"wait-for-expect": "^3.0.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,77 @@
/*
* Copyright 2023 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 { LoggerService } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
export type LogContent = { message: string; meta?: Error | JsonObject };
export type Logs = {
errors?: LogContent[];
warns?: LogContent[];
infos?: LogContent[];
debugs?: LogContent[];
};
export class MockedLogger implements LoggerService {
logs: Logs = {};
error(message: string, meta?: Error | JsonObject): void {
if (!this.logs.errors) {
this.logs.errors = [];
}
this.logs.errors!.push(toLogContent(message, meta));
}
warn(message: string, meta?: Error | JsonObject): void {
if (!this.logs.warns) {
this.logs.warns = [];
}
this.logs.warns!.push(toLogContent(message, meta));
}
info(message: string, meta?: Error | JsonObject): void {
if (!this.logs.infos) {
this.logs.infos = [];
}
this.logs.infos!.push(toLogContent(message, meta));
}
debug(message: string, meta?: Error | JsonObject): void {
if (!this.logs.debugs) {
this.logs.debugs = [];
}
this.logs.debugs!.push(toLogContent(message, meta));
}
child(_: JsonObject): LoggerService {
return this;
}
}
function toLogContent(message: string, meta?: Error | JsonObject): LogContent {
if (!meta) {
return { message };
}
const jsonObject = Object.fromEntries(Object.entries(meta));
if ('name' in meta) {
jsonObject.name = meta.name;
}
if ('message' in meta) {
jsonObject.message = meta.message;
}
return {
message,
meta: jsonObject,
};
}
@@ -0,0 +1,19 @@
/*
* Copyright 2023 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.
*/
export * from './loader';
export * from './scanner';
export * from './manager';
@@ -0,0 +1,50 @@
/*
* Copyright 2023 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 { ModuleLoader } from './types';
import { LoggerService } from '@backstage/backend-plugin-api';
import path from 'path';
export class CommonJSModuleLoader implements ModuleLoader {
constructor(public readonly logger: LoggerService) {}
async bootstrap(
backstageRoot: string,
dynamicPluginsPaths: string[],
): Promise<void> {
const allowedNodeModulesPaths = [
`${backstageRoot}/node_modules`,
...dynamicPluginsPaths.map(p => path.resolve(p, 'node_modules')),
];
const Module = require('module');
const oldNodeModulePaths = Module._nodeModulePaths;
Module._nodeModulePaths = (from: string): string[] => {
const result: string[] = oldNodeModulePaths(from);
if (!dynamicPluginsPaths.some(p => from.startsWith(p))) {
return result;
}
const filtered = result.filter(p => allowedNodeModulesPaths.includes(p));
this.logger.debug(
`Overriding node_modules search path for dynamic plugin ${from} to: ${filtered}`,
);
return filtered;
};
}
async load(packagePath: string): Promise<any> {
return await import(/* webpackIgnore: true */ packagePath);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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.
*/
export type { ModuleLoader } from './types';
@@ -0,0 +1,24 @@
/*
* Copyright 2023 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.
*/
/**
* @public
*/
export interface ModuleLoader {
bootstrap(backstageRoot: string, dynamicPluginPaths: string[]): Promise<void>;
load(id: string): Promise<any>;
}
@@ -0,0 +1,38 @@
/*
* Copyright 2023 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.
*/
export { isBackendDynamicPluginInstaller } from './types';
export type {
BaseDynamicPlugin,
DynamicPlugin,
FrontendDynamicPlugin,
BackendDynamicPlugin,
BackendDynamicPluginInstaller,
NewBackendPluginInstaller,
LegacyBackendPluginInstaller,
LegacyPluginEnvironment,
BackendPluginProvider,
} from './types';
export {
PluginManager,
dynamicPluginsFeatureDiscoveryServiceFactory,
dynamicPluginsServiceFactory,
dynamicPluginsServiceRef,
} from './plugin-manager';
export type { DynamicPluginsFactoryOptions } from './plugin-manager';
@@ -0,0 +1,542 @@
/*
* Copyright 2023 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 { PluginManager, dynamicPluginsServiceFactory } from './plugin-manager';
import {
BackendFeature,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import mockFs, { directory, symlink } from 'mock-fs';
import * as path from 'path';
import * as url from 'url';
import {
BackendDynamicPlugin,
BaseDynamicPlugin,
DynamicPlugin,
LegacyBackendPluginInstaller,
NewBackendPluginInstaller,
LegacyPluginEnvironment,
} from './types';
import { ScannedPluginManifest, ScannedPluginPackage } from '../scanner/types';
import { randomUUID } from 'crypto';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import {
createSpecializedBackend,
rootLifecycleServiceFactory,
} from '@backstage/backend-app-api';
import { ConfigSources } from '@backstage/config-loader';
import { Logs, MockedLogger, LogContent } from '../__testUtils__/testUtils';
import { PluginScanner } from '../scanner/plugin-scanner';
import { findPaths } from '@backstage/cli-common';
describe('backend-plugin-manager', () => {
describe('loadPlugins', () => {
afterEach(() => {
mockFs.restore();
jest.resetModules();
});
type TestCase = {
name: string;
packageManifest: ScannedPluginManifest;
indexFile?: {
retativePath: string[];
content?: string;
};
expectedLogs?(location: URL): {
errors?: LogContent[];
warns?: LogContent[];
infos?: LogContent[];
debugs?: LogContent[];
};
checkLoadedPlugins: (loadedPlugins: BaseDynamicPlugin[]) => void;
};
it.each<TestCase>([
{
name: 'should successfully load a new backend plugin',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content:
'exports.dynamicPluginInstaller={ kind: "new", install: () => [] }',
},
expectedLogs(location) {
return {
infos: [
{
message: `loaded dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`,
},
],
};
},
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()).toEqual<
BackendFeature | BackendFeature[]
>([]);
},
},
{
name: 'should successfully load a new backend plugin module',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin-module',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content:
'exports.dynamicPluginInstaller={ kind: "new", install: () => [] }',
},
expectedLogs(location) {
return {
infos: [
{
message: `loaded dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`,
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([
{
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
role: 'backend-plugin-module',
platform: 'node',
installer: {
kind: 'new',
},
},
]);
const installer: NewBackendPluginInstaller = (
plugins[0] as BackendDynamicPlugin
).installer as NewBackendPluginInstaller;
expect(installer.install()).toEqual<
BackendFeature | BackendFeature[]
>([]);
},
},
{
name: 'should fail when no index file',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
expectedLogs(location) {
const loadOrigin = 'manager/plugin-manager.test.ts';
/*
if (process.env.CI === 'true') {
// CI runs the tests in a different way which produces a different relative path
loadOrigin =
'../../../packages/backend-plugin-manager/src/manager/plugin-manager.test.ts';
}
*/
return {
errors: [
{
message: `an error occured while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`,
meta: {
name: 'Error',
message: `Cannot find module '${url.fileURLToPath(
location,
)}/dist/index.cjs.js' from '${loadOrigin}'`,
_originalMessage: `Cannot find module '${url.fileURLToPath(
location,
)}/dist/index.cjs.js' from '${loadOrigin}'`,
code: 'MODULE_NOT_FOUND',
hint: '',
moduleName: `${url.fileURLToPath(
location,
)}/dist/index.cjs.js`,
siblingWithSimilarExtensionFound: false,
requireStack: undefined,
},
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([]);
},
},
{
name: 'should fail when the expected entry point is not in the index file',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content: '',
},
expectedLogs(location) {
return {
errors: [
{
message: `dynamic backend plugin 'backend-dynamic-plugin-test' could not be loaded from '${location}': the module should export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field.`,
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([]);
},
},
{
name: 'should fail when the expected entry point is not of the expected type',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content:
'exports.dynamicPluginInstaller={ something: "else", unexpectedMethod() {} }',
},
expectedLogs(location) {
return {
errors: [
{
message: `dynamic backend plugin 'backend-dynamic-plugin-test' could not be loaded from '${location}': the module should export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field.`,
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([]);
},
},
{
name: 'should fail when the index file has a syntax error',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content: 'strange text with syntax error',
},
expectedLogs(location) {
return {
errors: [
{
message: `an error occured while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`,
meta: {
message: 'Unexpected identifier',
name: 'SyntaxError',
},
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([]);
},
},
{
name: 'should successfully load a legacy backend plugin',
packageManifest: {
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
},
indexFile: {
retativePath: ['dist', 'index.cjs.js'],
content:
'exports.dynamicPluginInstaller={ kind: "legacy", scaffolder: (env)=>[] }',
},
expectedLogs(location) {
return {
infos: [
{
message: `loaded dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`,
},
],
};
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([
{
name: 'backend-dynamic-plugin-test',
version: '0.0.0',
role: 'backend-plugin',
platform: 'node',
installer: {
kind: 'legacy',
},
},
]);
const installer = (plugins[0] as BackendDynamicPlugin)
.installer as LegacyBackendPluginInstaller;
expect(installer.scaffolder!({} as LegacyPluginEnvironment)).toEqual<
TemplateAction<any>[]
>([]);
},
},
{
name: 'should successfully load a frontend plugin',
packageManifest: {
name: 'frontend-dynamic-plugin-test',
version: '0.0.0',
backstage: {
role: 'frontend-plugin',
},
main: 'dist/index.esm.js',
},
checkLoadedPlugins(plugins) {
expect(plugins).toMatchObject([
{
name: 'frontend-dynamic-plugin-test',
version: '0.0.0',
role: 'frontend-plugin',
platform: 'web',
},
]);
},
},
])('$name', async (tc: TestCase): Promise<void> => {
const plugin: ScannedPluginPackage = {
location: new URL(`file:///node_modules/jest-tests/${randomUUID()}`),
manifest: tc.packageManifest,
};
const mockedFiles = {
[path.join(url.fileURLToPath(plugin.location), 'package.json')]:
mockFs.file({
content: JSON.stringify(plugin),
}),
};
if (tc.indexFile) {
mockedFiles[
path.join(
url.fileURLToPath(plugin.location),
...tc.indexFile.retativePath,
)
] = mockFs.file({
content: tc.indexFile.content,
});
}
mockFs(mockedFiles);
const logger = new MockedLogger();
const pluginManager = new (PluginManager as any)(logger, [plugin], {
logger,
async bootstrap(_: string, __: string[]): Promise<void> {},
load: async (packagePath: string) =>
await import(/* webpackIgnore: true */ packagePath),
});
const loadedPlugins: DynamicPlugin[] = await pluginManager.loadPlugins();
const expectedLogs = tc.expectedLogs
? tc.expectedLogs(plugin.location)
: {};
expect(logger.logs).toEqual<Logs>(expectedLogs);
tc.checkLoadedPlugins(loadedPlugins);
});
});
describe('backendPlugins', () => {
it('should return only backend plugins and modules', async () => {
const logger = new MockedLogger();
const pluginManager = new (PluginManager as any)(logger, '', []);
const plugins: BaseDynamicPlugin[] = [
{
name: 'a-frontend-plugin',
platform: 'web',
role: 'frontend-plugin',
version: '0.0.0',
},
{
name: 'a-backend-plugin',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
{
name: 'a-backend-module',
platform: 'node',
role: 'backend-plugin-module',
version: '0.0.0',
},
];
pluginManager.plugins = plugins;
expect(pluginManager.backendPlugins()).toEqual([
{
name: 'a-backend-plugin',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
{
name: 'a-backend-module',
platform: 'node',
role: 'backend-plugin-module',
version: '0.0.0',
},
]);
});
});
describe('dynamicPluginsServiceFactory', () => {
afterEach(() => {
mockFs.restore();
jest.resetModules();
});
it('should call PluginManager.fromConfig', async () => {
const logger = new MockedLogger();
const rootLogger = new MockedLogger();
mockFs({
[findPaths(__dirname).resolveTargetRoot('package.json')]: mockFs.load(
findPaths(__dirname).resolveTargetRoot('package.json'),
),
'/somewhere/dynamic-plugins-root/a-dynamic-plugin': symlink({
path: '/somewhere-else/a-dynamic-plugin',
}),
'/somewhere-else/a-dynamic-plugin': directory({}),
});
const fromConfigSpier = jest.spyOn(PluginManager, 'fromConfig');
const applyConfigSpier = jest
.spyOn(PluginScanner.prototype as any, 'applyConfig')
.mockImplementation(() => {});
const scanRootSpier = jest
.spyOn(PluginScanner.prototype, 'scanRoot')
.mockImplementation(async () => [
{
location: new URL(
'file:///somewhere/dynamic-plugins-root/a-dynamic-plugin',
),
manifest: {
name: 'test',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: {
role: 'backend-plugin',
},
},
},
]);
const mockedModuleLoader = {
logger,
bootstrap: jest.fn(),
load: jest.fn(),
};
const backend = createSpecializedBackend({
defaultServiceFactories: [
rootLifecycleServiceFactory(),
createServiceFactory({
service: coreServices.rootConfig,
deps: {},
async factory({}) {
return await ConfigSources.toConfig({
async *readConfigData() {
yield {
configs: [
{
context: 'test',
data: {},
},
],
};
},
});
},
}),
createServiceFactory({
service: coreServices.logger,
deps: {},
async factory({}) {
return logger;
},
}),
createServiceFactory({
service: coreServices.rootLogger,
deps: {},
async factory({}) {
return rootLogger;
},
}),
dynamicPluginsServiceFactory({
moduleLoader: _ => mockedModuleLoader,
}),
],
});
await backend.start();
expect(fromConfigSpier).toHaveBeenCalled();
expect(applyConfigSpier).toHaveBeenCalled();
expect(scanRootSpier).toHaveBeenCalled();
expect(mockedModuleLoader.bootstrap).toHaveBeenCalledWith(
findPaths(__dirname).targetRoot,
['/somewhere-else/a-dynamic-plugin'],
);
expect(mockedModuleLoader.load).toHaveBeenCalledWith(
'/somewhere/dynamic-plugins-root/a-dynamic-plugin/dist/index.cjs.js',
);
});
});
});
@@ -0,0 +1,265 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import {
BackendPluginProvider,
BackendDynamicPlugin,
isBackendDynamicPluginInstaller,
DynamicPlugin,
} from './types';
import { ScannedPluginPackage } from '../scanner';
import { PluginScanner } from '../scanner/plugin-scanner';
import { ModuleLoader } from '../loader';
import { CommonJSModuleLoader } from '../loader/CommonJSModuleLoader';
import * as url from 'url';
import {
BackendFeature,
LoggerService,
coreServices,
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { PackageRoles } from '@backstage/cli-node';
import { findPaths } from '@backstage/cli-common';
import path from 'path';
import * as fs from 'fs';
import {
FeatureDiscoveryService,
featureDiscoveryServiceRef,
} from '@backstage/backend-plugin-api/alpha';
/**
* @public
*/
export class PluginManager implements BackendPluginProvider {
static async fromConfig(
config: Config,
logger: LoggerService,
preferAlpha: boolean = false,
mooduleLoader: ModuleLoader = new CommonJSModuleLoader(logger),
): Promise<PluginManager> {
/* eslint-disable-next-line no-restricted-syntax */
const backstageRoot = findPaths(__dirname).targetRoot;
const scanner = new PluginScanner(
config,
logger,
backstageRoot,
preferAlpha,
);
const scannedPlugins = await scanner.scanRoot();
scanner.trackChanges();
const manager = new PluginManager(logger, scannedPlugins, mooduleLoader);
const dynamicPluginsPaths = scannedPlugins.map(p =>
fs.realpathSync(
path.dirname(
path.dirname(
path.resolve(url.fileURLToPath(p.location), p.manifest.main),
),
),
),
);
mooduleLoader.bootstrap(backstageRoot, dynamicPluginsPaths);
scanner.subscribeToRootDirectoryChange(async () => {
manager._availablePackages = await scanner.scanRoot();
// TODO: do not store _scannedPlugins again, but instead store a diff of the changes
});
manager.plugins.push(...(await manager.loadPlugins()));
return manager;
}
readonly plugins: DynamicPlugin[];
private _availablePackages: ScannedPluginPackage[];
private constructor(
private readonly logger: LoggerService,
private packages: ScannedPluginPackage[],
private readonly moduleLoader: ModuleLoader,
) {
this.plugins = [];
this._availablePackages = packages;
}
get availablePackages(): ScannedPluginPackage[] {
return this._availablePackages;
}
addBackendPlugin(plugin: BackendDynamicPlugin): void {
this.plugins.push(plugin);
}
private async loadPlugins(): Promise<DynamicPlugin[]> {
const loadedPlugins: DynamicPlugin[] = [];
for (const scannedPlugin of this.packages) {
const platform = PackageRoles.getRoleInfo(
scannedPlugin.manifest.backstage.role,
).platform;
if (
platform === 'node' &&
scannedPlugin.manifest.backstage.role.includes('-plugin')
) {
const plugin = await this.loadBackendPlugin(scannedPlugin);
if (plugin !== undefined) {
loadedPlugins.push(plugin);
}
} else {
loadedPlugins.push({
name: scannedPlugin.manifest.name,
version: scannedPlugin.manifest.version,
role: scannedPlugin.manifest.backstage.role,
platform: 'web',
// TODO(davidfestal): add required front-end plugin information here.
});
}
}
return loadedPlugins;
}
private async loadBackendPlugin(
plugin: ScannedPluginPackage,
): Promise<BackendDynamicPlugin | undefined> {
const packagePath = url.fileURLToPath(
`${plugin.location}/${plugin.manifest.main}`,
);
try {
const { dynamicPluginInstaller } = await this.moduleLoader.load(
packagePath,
);
if (!isBackendDynamicPluginInstaller(dynamicPluginInstaller)) {
this.logger.error(
`dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': the module should export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field.`,
);
return undefined;
}
this.logger.info(
`loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`,
);
return {
name: plugin.manifest.name,
version: plugin.manifest.version,
platform: 'node',
role: plugin.manifest.backstage.role,
installer: dynamicPluginInstaller,
};
} catch (error) {
this.logger.error(
`an error occured while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`,
error,
);
return undefined;
}
}
backendPlugins(): BackendDynamicPlugin[] {
return this.plugins.filter(
(p): p is BackendDynamicPlugin => p.platform === 'node',
);
}
}
/**
* @public
*/
export const dynamicPluginsServiceRef = createServiceRef<BackendPluginProvider>(
{
id: 'core.dynamicplugins',
scope: 'root',
},
);
/**
* @public
*/
export interface DynamicPluginsFactoryOptions {
moduleLoader?(logger: LoggerService): ModuleLoader;
}
/**
* @public
*/
export const dynamicPluginsServiceFactory = createServiceFactory(
(options?: DynamicPluginsFactoryOptions) => ({
service: dynamicPluginsServiceRef,
deps: {
config: coreServices.rootConfig,
logger: coreServices.rootLogger,
},
async factory({ config, logger }) {
if (options?.moduleLoader) {
return await PluginManager.fromConfig(
config,
logger,
true,
options.moduleLoader(logger),
);
}
return await PluginManager.fromConfig(config, logger, true);
},
}),
);
class DynamicPluginsEnabledFeatureDiscoveryService
implements FeatureDiscoveryService
{
constructor(
private readonly dynamicPlugins: BackendPluginProvider,
private readonly featureDiscoveryService?: FeatureDiscoveryService,
) {}
async getBackendFeatures(): Promise<{ features: Array<BackendFeature> }> {
const staticFeatures =
(await this.featureDiscoveryService?.getBackendFeatures())?.features ??
[];
return {
features: [
...this.dynamicPlugins
.backendPlugins()
.flatMap((plugin): BackendFeature[] => {
if (plugin.installer.kind === 'new') {
const installed = plugin.installer.install();
if (Array.isArray(installed)) {
return installed;
}
return [installed];
}
return [];
}),
...staticFeatures,
],
};
}
}
/**
* @public
*/
export const dynamicPluginsFeatureDiscoveryServiceFactory =
createServiceFactory({
service: featureDiscoveryServiceRef,
deps: {
config: coreServices.rootConfig,
dynamicPlugins: dynamicPluginsServiceRef,
},
factory({ dynamicPlugins }) {
return new DynamicPluginsEnabledFeatureDiscoveryService(dynamicPlugins);
},
});
@@ -0,0 +1,169 @@
/*
* Copyright 2023 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 { Logger } from 'winston';
import { Config } from '@backstage/config';
import {
PluginCacheManager,
PluginDatabaseManager,
PluginEndpointDiscovery,
TokenManager,
UrlReader,
} from '@backstage/backend-common';
import { Router } from 'express';
import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
EventBroker,
HttpPostIngressOptions,
} from '@backstage/plugin-events-node';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { PackagePlatform, PackageRole } from '@backstage/cli-node';
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { IndexBuilder } from '@backstage/plugin-search-backend-node';
import { EventsBackend } from '@backstage/plugin-events-backend';
import { PermissionPolicy } from '@backstage/plugin-permission-node';
/**
* @public
*
* @deprecated
*
* Support for the legacy backend system will be removed in the future.
*
* When adding a legacy plugin installer entrypoint in your plugin,
* you should always take the opportunity to also implement support
* for the new backend system if not already done.
*
*/
export type LegacyPluginEnvironment = {
logger: Logger;
cache: PluginCacheManager;
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
permissions: PermissionEvaluator;
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
pluginProvider: BackendPluginProvider;
};
/**
* @public
*/
export interface BackendPluginProvider {
backendPlugins(): BackendDynamicPlugin[];
}
/**
* @public
*/
export interface BaseDynamicPlugin {
name: string;
version: string;
role: PackageRole;
platform: PackagePlatform;
}
/**
* @public
*/
export type DynamicPlugin = FrontendDynamicPlugin | BackendDynamicPlugin;
/**
* @public
*/
export interface FrontendDynamicPlugin extends BaseDynamicPlugin {
platform: 'web';
}
/**
* @public
*/
export interface BackendDynamicPlugin extends BaseDynamicPlugin {
platform: 'node';
installer: BackendDynamicPluginInstaller;
}
/**
* @public
*/
export type BackendDynamicPluginInstaller =
| LegacyBackendPluginInstaller
| NewBackendPluginInstaller;
/**
* @public
*/
export interface NewBackendPluginInstaller {
kind: 'new';
install(): BackendFeature | BackendFeature[];
}
/**
* @public
* @deprecated
*
* Support for the legacy backend system will be removed in the future.
*
* When adding a legacy plugin installer entrypoint in your plugin,
* you should always take the opportunity to also implement support
* for the new backend system if not already done.
*
*/
export interface LegacyBackendPluginInstaller {
kind: 'legacy';
router?: {
pluginID: string;
createPlugin(env: LegacyPluginEnvironment): Promise<Router>;
};
catalog?(builder: CatalogBuilder, env: LegacyPluginEnvironment): void;
scaffolder?(env: LegacyPluginEnvironment): TemplateAction<any>[];
search?(
indexBuilder: IndexBuilder,
schedule: TaskRunner,
env: LegacyPluginEnvironment,
): void;
events?(
eventsBackend: EventsBackend,
env: LegacyPluginEnvironment,
): HttpPostIngressOptions[];
permissions?: {
policy?: PermissionPolicy;
};
}
/**
* @public
*/
export function isBackendDynamicPluginInstaller(
obj: any,
): obj is BackendDynamicPluginInstaller {
return (
obj !== undefined &&
'kind' in obj &&
(obj.kind === 'new' || obj.kind === 'legacy')
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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.
*/
export type { ScannedPluginManifest, ScannedPluginPackage } from './types';
@@ -0,0 +1,365 @@
/*
* Copyright 2023 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 { PluginScanner } from './plugin-scanner';
import { Logs, MockedLogger } from '../__testUtils__/testUtils';
import { Config, ConfigReader } from '@backstage/config';
import path, { join } from 'path';
import { ScannedPluginPackage } from './types';
import { mkdtempSync, rmSync } from 'fs';
import { mkdir, writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import waitForExpect from 'wait-for-expect';
describe('plugin-scanner', () => {
describe('rootDirectoryWatcher', () => {
let backstageRootDirectory: string;
beforeEach(() => {
backstageRootDirectory = mkdtempSync(
path.join(tmpdir(), 'backstage-plugin-scanner-test'),
);
});
afterEach(() => {
if (backstageRootDirectory) {
rmSync(backstageRootDirectory, { recursive: true, force: true });
}
});
process.on('beforeExit', () => {
if (backstageRootDirectory) {
rmSync(backstageRootDirectory, { recursive: true, force: true });
}
});
it('watch for config and file system changes', async () => {
const logger = new MockedLogger();
await mkdir(join(backstageRootDirectory, 'first-dir'));
await mkdir(join(backstageRootDirectory, 'second-dir'));
const config: Config = new ConfigReader({
dynamicPlugins: {
rootDirectory: 'first-dir',
},
});
const getOptional = jest.spyOn(config, 'getOptional');
let onConfigChange: (() => Promise<void>) | undefined;
const configUnsubscribe = jest.fn();
config.subscribe = onChange => {
onConfigChange = onChange as any;
return {
unsubscribe: configUnsubscribe,
};
};
const pluginScanner = new PluginScanner(
config,
logger,
backstageRootDirectory,
false,
);
await pluginScanner.trackChanges();
expect(onConfigChange).toBeDefined();
let scannedPlugins: ScannedPluginPackage[] =
await pluginScanner.scanRoot();
expect(scannedPlugins).toEqual([]);
const rootDirectorySubscriber = jest.fn(async () => {
scannedPlugins = await pluginScanner.scanRoot();
});
pluginScanner.subscribeToRootDirectoryChange(rootDirectorySubscriber);
await mkdir(
join(backstageRootDirectory, 'first-dir', 'test-backend-plugin'),
);
await writeFile(
join(
backstageRootDirectory,
'first-dir',
'test-backend-plugin',
'package.json',
),
JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
);
await waitForExpect(() => {
expect(rootDirectorySubscriber).toHaveBeenCalledTimes(1);
});
rootDirectorySubscriber.mockClear();
await waitForExpect(() => {
expect(scannedPlugins).toEqual([
{
location: new URL(
`file://${backstageRootDirectory}/first-dir/test-backend-plugin`,
),
manifest: {
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
},
},
]);
});
expect(logger.logs).toEqual<Logs>({
infos: [
{
message: `rootDirectory changed (addDir - ${backstageRootDirectory}/first-dir/test-backend-plugin): scanning plugins again`,
},
{
message: `rootDirectory changed (add - ${backstageRootDirectory}/first-dir/test-backend-plugin/package.json): scanning plugins again`,
},
],
});
logger.logs = {};
getOptional.mockReturnValue({
rootDirectory: 'second-dir',
});
await onConfigChange!();
await waitForExpect(() => {
expect(rootDirectorySubscriber).toHaveBeenCalledTimes(1);
});
rootDirectorySubscriber.mockClear();
await waitForExpect(() => {
expect(scannedPlugins).toEqual([]);
});
expect(logger.logs).toEqual<Logs>({
infos: [
{
message: `rootDirectory changed in Config from '${backstageRootDirectory}/first-dir' to '${backstageRootDirectory}/second-dir'`,
},
],
});
logger.logs = {};
await mkdir(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
),
);
await writeFile(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'package.json',
),
JSON.stringify({
name: 'second-test-backend-plugin-dynamic',
version: '1.0.3',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
);
await waitForExpect(() => {
expect(rootDirectorySubscriber).toHaveBeenCalledTimes(1);
});
rootDirectorySubscriber.mockClear();
await waitForExpect(() => {
expect(scannedPlugins).toEqual([
{
location: new URL(
`file://${backstageRootDirectory}/second-dir/second-test-backend-plugin`,
),
manifest: {
backstage: {
role: 'backend-plugin',
},
main: 'dist/index.cjs.js',
name: 'second-test-backend-plugin-dynamic',
version: '1.0.3',
},
},
]);
});
expect(logger.logs).toEqual<Logs>({
infos: [
{
message: `rootDirectory changed (addDir - ${backstageRootDirectory}/second-dir/second-test-backend-plugin): scanning plugins again`,
},
{
message: `rootDirectory changed (add - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/package.json): scanning plugins again`,
},
],
});
logger.logs = {};
const debug = jest.spyOn(logger, 'debug');
// Check that not all file changes trigger a new scan of plugins
await mkdir(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'sub-directory',
),
);
await waitForExpect(() => {
expect(debug).toHaveBeenCalledTimes(1);
});
await writeFile(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'not-package.json',
),
'content',
);
await waitForExpect(() => {
expect(debug).toHaveBeenCalledTimes(2);
});
await rm(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'not-package.json',
),
);
await waitForExpect(() => {
expect(debug).toHaveBeenCalledTimes(3);
});
await rm(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'sub-directory',
),
{ recursive: true },
);
await waitForExpect(() => {
expect(debug).toHaveBeenCalledTimes(4);
});
expect(logger.logs).toEqual<Logs>({
debugs: [
{
message: `rootDirectory changed (addDir - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/sub-directory): no need to scan plugins again`,
},
{
message: `rootDirectory changed (add - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/not-package.json): no need to scan plugins again`,
},
{
message: `rootDirectory changed (unlink - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/not-package.json): no need to scan plugins again`,
},
{
message: `rootDirectory changed (unlinkDir - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/sub-directory): no need to scan plugins again`,
},
],
});
logger.logs = {};
// Now check that removal of some plugin home directory triggers a new scan of plugins
await rm(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
'package.json',
),
);
await waitForExpect(() => {
expect(rootDirectorySubscriber).toHaveBeenCalledTimes(1);
});
rootDirectorySubscriber.mockClear();
await waitForExpect(() => {
expect(scannedPlugins).toEqual([]);
});
expect(logger.logs).toEqual<Logs>({
infos: [
{
message: `rootDirectory changed (unlink - ${backstageRootDirectory}/second-dir/second-test-backend-plugin/package.json): scanning plugins again`,
},
],
errors: [
{
message: `failed to load dynamic plugin manifest from '${backstageRootDirectory}/second-dir/second-test-backend-plugin'`,
meta: {
code: 'ENOENT',
errno: -2,
message: `ENOENT: no such file or directory, open '${backstageRootDirectory}/second-dir/second-test-backend-plugin/package.json'`,
name: 'Error',
path: `${backstageRootDirectory}/second-dir/second-test-backend-plugin/package.json`,
syscall: 'open',
},
},
],
});
logger.logs = {};
await rm(
join(
backstageRootDirectory,
'second-dir',
'second-test-backend-plugin',
),
{ recursive: true },
);
await waitForExpect(() => {
expect(rootDirectorySubscriber).toHaveBeenCalledTimes(1);
});
rootDirectorySubscriber.mockClear();
expect(logger.logs).toEqual<Logs>({
infos: [
{
message: `rootDirectory changed (unlinkDir - ${backstageRootDirectory}/second-dir/second-test-backend-plugin): scanning plugins again`,
},
],
});
logger.logs = {};
getOptional.mockReturnValue({
rootDirectory: '/somewhere-else/second-dir',
});
await onConfigChange!();
expect(logger.logs).toEqual<Logs>({
errors: [
{
message: 'failed to apply new config for dynamic plugins',
meta: {
message: `Dynamic plugins under '/somewhere-else/second-dir' cannot access backstage modules in '${backstageRootDirectory}/node_modules'.
Please add '${backstageRootDirectory}/node_modules' to the 'NODE_PATH' when running the backstage backend.`,
name: 'Error',
},
},
],
});
}, 120000);
});
});
@@ -0,0 +1,720 @@
/*
* Copyright 2023 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 { PluginScanner } from './plugin-scanner';
import mockFs from 'mock-fs';
import { JsonObject } from '@backstage/types';
import { Logs, MockedLogger } from '../__testUtils__/testUtils';
import { ConfigReader } from '@backstage/config';
import path from 'path';
import { ScannedPluginPackage } from './types';
describe('plugin-scanner', () => {
const env = process.env;
beforeEach(() => {
jest.resetModules();
});
afterEach(() => {
mockFs.restore();
process.env = env;
});
describe('applyConfig', () => {
type TestCase = {
name: string;
backstageRoot?: string;
fileSystem?: any;
config: JsonObject;
environment?: { [key: string]: string };
expectedLogs?: Logs;
expectedRootDirectory?: string;
expectedError?: string;
};
it.each<TestCase>([
{
name: 'no dynamic plugins',
config: {},
expectedRootDirectory: undefined,
expectedLogs: {
infos: [
{
message: "'dynamicPlugins' config entry not found.",
},
],
},
},
{
name: 'valid config with relative root directory path',
backstageRoot: '/backstageRoot',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory(),
},
}),
},
config: {
dynamicPlugins: {
rootDirectory: 'dist-dynamic',
},
},
expectedRootDirectory: '/backstageRoot/dist-dynamic',
},
{
name: 'valid config with absolute root directory path inside the backstage root',
backstageRoot: '/backstageRoot',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory(),
},
}),
},
config: {
dynamicPlugins: {
rootDirectory: '/backstageRoot/dist-dynamic',
},
},
expectedRootDirectory: '/backstageRoot/dist-dynamic',
},
{
name: 'valid config with absolute root directory path outside the backstage root',
backstageRoot: '/backstageRoot',
fileSystem: {
'/somewhere': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory(),
},
}),
},
config: {
dynamicPlugins: {
rootDirectory: '/somewhere/dist-dynamic',
},
},
expectedError: `Dynamic plugins under '/somewhere/dist-dynamic' cannot access backstage modules in '/backstageRoot/node_modules'.
Please add '/backstageRoot/node_modules' to the 'NODE_PATH' when running the backstage backend.`,
},
{
name: 'valid config with absolute root directory path outside the backstage root but with backstage root included in NODE_PATH',
backstageRoot: '/backstageRoot',
fileSystem: {
'/somewhere': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory(),
},
}),
},
config: {
dynamicPlugins: {
rootDirectory: '/somewhere/dist-dynamic',
},
},
environment: {
NODE_PATH: `/somewhere-else${path.delimiter}/backstageRoot/node_modules${path.delimiter}/anywhere-else`,
},
expectedRootDirectory: '/somewhere/dist-dynamic',
},
{
name: 'invalid config: dynamicPlugins not an object',
config: {
dynamicPlugins: 1,
},
expectedLogs: {
warns: [
{
message: "'dynamicPlugins' config entry should be an object.",
},
],
},
},
{
name: 'invalid config: rootDirectory not a string',
config: {
dynamicPlugins: {
rootDirectory: 1,
},
},
expectedLogs: {
warns: [
{
message:
"'dynamicPlugins.rootDirectory' config entry should be a string.",
},
],
},
},
{
name: 'invalid config: no rootDirectory entry',
config: {
dynamicPlugins: {},
},
expectedLogs: {
warns: [
{
message:
"'dynamicPlugins' config entry does not contain the 'rootDirectory' field.",
},
],
},
},
{
name: 'valid config pointing to a file instead of a directory',
backstageRoot: '/backstageRoot',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.file(),
},
}),
},
config: {
dynamicPlugins: {
rootDirectory: 'dist-dynamic',
},
},
expectedError: 'Not a directory',
},
])('$name', (tc: TestCase): void => {
if (tc.environment) {
process.env = {
...env,
...tc.environment,
};
}
const logger = new MockedLogger();
const backstageRoot = tc.backstageRoot ? tc.backstageRoot : '';
function toTest(): PluginScanner {
return new PluginScanner(
new ConfigReader(tc.config),
logger,
backstageRoot,
);
}
if (tc.fileSystem) {
mockFs(tc.fileSystem);
}
if (tc.expectedError) {
/* eslint-disable-next-line jest/no-conditional-expect */
expect(toTest).toThrow(tc.expectedError);
return;
}
const pluginScanner = toTest();
if (tc.expectedLogs) {
/* eslint-disable-next-line jest/no-conditional-expect */
expect(logger.logs).toEqual<Logs>(tc.expectedLogs);
} else {
/* eslint-disable-next-line jest/no-conditional-expect */
expect(logger.logs).toEqual<Logs>({});
}
expect(pluginScanner.rootDirectory).toEqual(tc.expectedRootDirectory);
});
});
describe('scanRoot', () => {
type TestCase = {
name: string;
preferAlpha?: boolean;
fileSystem?: any;
expectedLogs?: Logs;
expectedPluginPackages?: ScannedPluginPackage[];
expectedError?: string;
};
it.each<TestCase>([
{
name: 'dynamic plugins disabled',
expectedPluginPackages: [],
expectedLogs: {
infos: [
{
message: "'dynamicPlugins' config entry not found.",
},
],
},
},
{
name: 'manifest found in directory',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [
{
location: new URL(
'file:///backstageRoot/dist-dynamic/test-backend-plugin',
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
},
},
],
},
{
name: 'backend plugin found in symlink',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.symlink({
path: '/somewhere-else/test-backend-plugin-target',
}),
},
}),
},
}),
'/somewhere-else': mockFs.directory({
items: {
'test-backend-plugin-target': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
},
}),
},
}),
},
expectedPluginPackages: [
{
location: new URL(
'file:///backstageRoot/dist-dynamic/test-backend-plugin',
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
},
},
],
},
{
name: 'ignored folder child: not a directory',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.file({}),
},
}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
infos: [
{
message:
"skipping '/backstageRoot/dist-dynamic/test-backend-plugin' since it is not a directory",
},
],
},
},
{
name: 'ignored folder child symlink: target is not a directory',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.symlink({
path: '/somewhere-else/test-backend-plugin-target',
}),
},
}),
},
}),
'/somewhere-else': mockFs.directory({
items: {
'test-backend-plugin-target': mockFs.file({}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
infos: [
{
message:
"skipping '/backstageRoot/dist-dynamic/test-backend-plugin' since it is not a directory",
},
],
},
},
{
name: 'alpha manifest available but not preferred',
preferAlpha: false,
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
alpha: mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
}),
}),
},
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [
{
location: new URL(
'file:///backstageRoot/dist-dynamic/test-backend-plugin',
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
},
},
],
},
{
name: 'alpha manifest preferred and found in directory',
preferAlpha: true,
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
alpha: mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
}),
}),
},
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [
{
location: new URL(
'file:///backstageRoot/dist-dynamic/test-backend-plugin/alpha',
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: '../dist/alpha.cjs.js',
backstage: { role: 'backend-plugin' },
},
},
],
},
{
name: 'alpha manifest preferred but skipped because not a directory',
preferAlpha: true,
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
alpha: mockFs.file({}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [
{
location: new URL(
'file:///backstageRoot/dist-dynamic/test-backend-plugin',
),
manifest: {
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
},
},
],
expectedLogs: {
warns: [
{
message:
"skipping '/backstageRoot/dist-dynamic/test-backend-plugin/alpha' since it is not a directory",
},
],
},
},
{
name: 'invalid alpha package.json',
preferAlpha: true,
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: { role: 'backend-plugin' },
}),
}),
alpha: mockFs.directory({
items: {
'package.json': mockFs.file({
content: "invalid json content, 1, '",
}),
},
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
errors: [
{
message:
"failed to load dynamic plugin manifest from '/backstageRoot/dist-dynamic/test-backend-plugin/alpha'",
meta: {
name: 'SyntaxError',
message: 'Unexpected token i in JSON at position 0',
},
},
],
},
},
{
name: 'invalid package.json',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: "invalid json content, 1, '",
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
errors: [
{
message:
"failed to load dynamic plugin manifest from '/backstageRoot/dist-dynamic/test-backend-plugin'",
meta: {
name: 'SyntaxError',
message: 'Unexpected token i in JSON at position 0',
},
},
],
},
},
{
name: 'missing backstage role in package.json',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
main: 'dist/index.cjs.js',
}),
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
errors: [
{
message:
"failed to load dynamic plugin manifest from '/backstageRoot/dist-dynamic/test-backend-plugin'",
meta: {
name: 'Error',
message: "field 'backstage.role' not found in 'package.json'",
},
},
],
},
},
{
name: 'missing main field in package.json',
fileSystem: {
'/backstageRoot': mockFs.directory({
items: {
'dist-dynamic': mockFs.directory({
items: {
'test-backend-plugin': mockFs.directory({
items: {
'package.json': mockFs.file({
content: JSON.stringify({
name: 'test-backend-plugin-dynamic',
version: '0.0.0',
backstage: { role: 'backend-plugin' },
}),
}),
},
}),
},
}),
},
}),
},
expectedPluginPackages: [],
expectedLogs: {
errors: [
{
message:
"failed to load dynamic plugin manifest from '/backstageRoot/dist-dynamic/test-backend-plugin'",
meta: {
name: 'Error',
message: "field 'main' not found in 'package.json'",
},
},
],
},
},
])('$name', async (tc: TestCase): Promise<void> => {
const logger = new MockedLogger();
const backstageRoot = '/backstageRoot';
async function toTest(): Promise<ScannedPluginPackage[]> {
const pluginScanner = new PluginScanner(
new ConfigReader(
tc.fileSystem
? {
dynamicPlugins: {
rootDirectory: 'dist-dynamic',
},
}
: {},
),
logger,
backstageRoot,
tc.preferAlpha === undefined ? false : tc.preferAlpha,
);
return await pluginScanner.scanRoot();
}
if (tc.fileSystem) {
mockFs(tc.fileSystem);
}
if (tc.expectedError) {
/* eslint-disable-next-line jest/no-conditional-expect */
expect(toTest).toThrow(tc.expectedError);
return;
}
const plugins = await toTest();
expect(logger.logs).toEqual<Logs>(tc.expectedLogs ? tc.expectedLogs : {});
expect(plugins).toEqual(tc.expectedPluginPackages);
});
});
});
@@ -0,0 +1,304 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import { ScannedPluginPackage, ScannedPluginManifest } from './types';
import * as fs from 'fs/promises';
import { Stats, lstatSync } from 'fs';
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 class PluginScanner {
private readonly logger: LoggerService;
private backstageRoot: string;
readonly #config: Config;
private _rootDirectory?: string;
private readonly preferAlpha: boolean;
private configUnsubscribe?: () => void;
private rootDirectoryWatcher?: chokidar.FSWatcher;
private subscribers: (() => void)[] = [];
constructor(
config: Config,
logger: LoggerService,
backstageRoot: string,
preferAlpha: boolean = false,
) {
this.backstageRoot = backstageRoot;
this.logger = logger;
this.preferAlpha = preferAlpha;
this.#config = config;
this.applyConfig();
}
subscribeToRootDirectoryChange(subscriber: () => void) {
this.subscribers.push(subscriber);
}
get rootDirectory(): string | undefined {
return this._rootDirectory;
}
private applyConfig(): void | never {
const dynamicPlugins = this.#config.getOptional('dynamicPlugins');
if (!dynamicPlugins) {
this.logger.info("'dynamicPlugins' config entry not found.");
this._rootDirectory = undefined;
return;
}
if (typeof dynamicPlugins !== 'object') {
this.logger.warn("'dynamicPlugins' config entry should be an object.");
this._rootDirectory = undefined;
return;
}
if (!('rootDirectory' in dynamicPlugins)) {
this.logger.warn(
"'dynamicPlugins' config entry does not contain the 'rootDirectory' field.",
);
this._rootDirectory = undefined;
return;
}
if (typeof dynamicPlugins.rootDirectory !== 'string') {
this.logger.warn(
"'dynamicPlugins.rootDirectory' config entry should be a string.",
);
this._rootDirectory = undefined;
return;
}
const dynamicPluginsRootPath = path.isAbsolute(dynamicPlugins.rootDirectory)
? dynamicPlugins.rootDirectory
: path.resolve(this.backstageRoot, dynamicPlugins.rootDirectory);
if (
!path
.dirname(path.normalize(dynamicPluginsRootPath))
.startsWith(path.normalize(this.backstageRoot))
) {
const nodePath = process.env.NODE_PATH;
const backstageNodeModules = path.resolve(
this.backstageRoot,
'node_modules',
);
if (
!nodePath ||
!nodePath.split(path.delimiter).includes(backstageNodeModules)
) {
throw new Error(
`Dynamic plugins under '${dynamicPluginsRootPath}' cannot access backstage modules in '${backstageNodeModules}'.\n` +
`Please add '${backstageNodeModules}' to the 'NODE_PATH' when running the backstage backend.`,
);
}
}
if (!lstatSync(dynamicPluginsRootPath).isDirectory()) {
throw new Error('Not a directory');
}
this._rootDirectory = dynamicPluginsRootPath;
}
async scanRoot(): Promise<ScannedPluginPackage[]> {
if (!this._rootDirectory) {
return [];
}
const dynamicPluginsLocation = this._rootDirectory;
const scannedPlugins: ScannedPluginPackage[] = [];
for (const dirEnt of await fs.readdir(dynamicPluginsLocation, {
withFileTypes: true,
})) {
const pluginDir = dirEnt;
const pluginHome = path.normalize(
path.resolve(dynamicPluginsLocation, pluginDir.name),
);
if (dirEnt.isSymbolicLink()) {
if (!(await fs.lstat(await fs.readlink(pluginHome))).isDirectory()) {
this.logger.info(
`skipping '${pluginHome}' since it is not a directory`,
);
continue;
}
} else if (!dirEnt.isDirectory()) {
this.logger.info(
`skipping '${pluginHome}' since it is not a directory`,
);
continue;
}
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 {
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,
);
continue;
}
if (platform === 'node') {
if (this.preferAlpha) {
const pluginHomeAlpha = path.resolve(pluginHome, 'alpha');
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 scannedPlugins;
}
private async scanDir(pluginHome: string): Promise<ScannedPluginPackage> {
const manifestFile = path.resolve(pluginHome, 'package.json');
const content = await fs.readFile(manifestFile);
const manifest: ScannedPluginManifest = JSON.parse(content.toString());
return {
location: url.pathToFileURL(pluginHome),
manifest: manifest,
};
}
async trackChanges(): Promise<void> {
const setupRootDirectoryWatcher = async (): Promise<void> => {
return new Promise((resolve, reject) => {
if (!this._rootDirectory) {
resolve();
return;
}
const callSubscribers = debounce(() => {
this.subscribers.forEach(s => s());
}, 500);
let ready = false;
this.rootDirectoryWatcher = chokidar
.watch(this._rootDirectory, {
ignoreInitial: true,
followSymlinks: true,
})
.on(
'all',
(
event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir',
eventPath: string,
_: Stats | undefined,
): void => {
if (
(['addDir', 'unlinkDir'].includes(event) &&
path.dirname(eventPath) === this._rootDirectory) ||
(['add', 'unlink', 'change'].includes(event) &&
path.dirname(path.dirname(eventPath)) ===
this._rootDirectory &&
path.basename(eventPath) === 'package.json')
) {
this.logger.info(
`rootDirectory changed (${event} - ${eventPath}): scanning plugins again`,
);
callSubscribers();
} else {
this.logger.debug(
`rootDirectory changed (${event} - ${eventPath}): no need to scan plugins again`,
);
}
},
)
.on('error', (error: Error) => {
this.logger.error(
`error while watching '${this.rootDirectory}'`,
error,
);
if (!ready) {
reject(error);
}
})
.on('ready', () => {
ready = true;
resolve();
});
});
};
await setupRootDirectoryWatcher();
if (this.#config.subscribe) {
const { unsubscribe } = this.#config.subscribe(
async (): Promise<void> => {
const oldRootDirectory = this._rootDirectory;
try {
this.applyConfig();
} catch (e) {
this.logger.error(
'failed to apply new config for dynamic plugins',
e,
);
}
if (oldRootDirectory !== this._rootDirectory) {
this.logger.info(
`rootDirectory changed in Config from '${oldRootDirectory}' to '${this._rootDirectory}'`,
);
this.subscribers.forEach(s => s());
if (this.rootDirectoryWatcher) {
await this.rootDirectoryWatcher.close();
}
await setupRootDirectoryWatcher();
}
},
);
this.configUnsubscribe = unsubscribe;
}
}
async untrackChanges() {
if (this.rootDirectoryWatcher) {
this.rootDirectoryWatcher.close();
}
if (this.configUnsubscribe) {
this.configUnsubscribe();
}
}
destructor() {
this.untrackChanges();
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2023 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 { BackstagePackageJson } from '@backstage/cli-node';
/**
* @public
*/
export interface ScannedPluginPackage {
location: URL;
manifest: ScannedPluginManifest;
}
/**
* @public
*/
export type ScannedPluginManifest = BackstagePackageJson &
Required<Pick<BackstagePackageJson, 'main'>> &
Required<Pick<BackstagePackageJson, 'backstage'>> & {
backstage: Required<BackstagePackageJson['backstage']>;
};
+35
View File
@@ -3577,6 +3577,41 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/backend-plugin-manager@workspace:packages/backend-plugin-manager":
version: 0.0.0-use.local
resolution: "@backstage/backend-plugin-manager@workspace:packages/backend-plugin-manager"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/cli-common": "workspace:^"
"@backstage/cli-node": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-backend": "workspace:^"
"@backstage/plugin-events-backend": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
chokidar: ^3.5.3
express: ^4.17.1
lodash: ^4.17.21
mock-fs: ^5.2.0
wait-for-expect: ^3.0.2
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/backend-tasks@workspace:^, @backstage/backend-tasks@workspace:packages/backend-tasks":
version: 0.0.0-use.local
resolution: "@backstage/backend-tasks@workspace:packages/backend-tasks"