diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index b1f6cf79e7..567f8b75ca 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -76,6 +76,8 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/plugin-app-backend": "workspace:^", + "triple-beam": "^1.4.1", "wait-for-expect": "^3.0.2" } } diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore b/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore new file mode 100644 index 0000000000..8f91d44d47 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore @@ -0,0 +1,2 @@ +!dist +!node_modules diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json new file mode 100644 index 0000000000..445c79a413 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-backend": { + "type": "object", + "required": [ + "secretValue" + ], + "properties": { + "secretValue": { + "type": "string", + "visibility": "secret" + } + } + } + } +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js new file mode 100644 index 0000000000..6cc4a4f125 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js @@ -0,0 +1,59 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +const { dynamicPluginsServiceRef } = require('../../../../../manager'); +var backendPluginApi = require('@backstage/backend-plugin-api'); +const express = require('express'); +const path = require('path'); +const url = require('url'); + +const privateDep = require('private-dep-with-frontend-plugin-index-path'); + +const testPlugin = backendPluginApi.createBackendPlugin({ + pluginId: "test", + register(env) { + env.registerInit({ + deps: { + http: backendPluginApi.coreServices.httpRouter, + logger: backendPluginApi.coreServices.rootLogger, + discovery: backendPluginApi.coreServices.discovery, + dynamicPlugins: dynamicPluginsServiceRef, + metadata: backendPluginApi.coreServices.pluginMetadata, + }, + async init({ + http, + logger, + discovery, + dynamicPlugins, + metadata, + }) { + logger.info("This secret value should be hidden by the dynamic-plugin-aware logger: AVerySecretValue"); + const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId()); + const router = express.Router(); + const frontendPluginsIndexPath = privateDep.frontendPluginsIndexPath; + const frontendPluginManifests = Object.fromEntries(dynamicPlugins.frontendPlugins().map(fp => { + const pluginScannedPackage = dynamicPlugins.getScannedPackage(fp); + const pkgDistLocation = path.resolve( + url.fileURLToPath(pluginScannedPackage.location), + 'dist', + ); + router.use(`/${frontendPluginsIndexPath}/${fp.name}`, express.static(pkgDistLocation)) + return [fp.name, `${externalBaseUrl}/${frontendPluginsIndexPath}/${fp.name}/mf-manifest.json`] + })); + router.get(`/${frontendPluginsIndexPath}`, (req, res) => { + res.status(200).json(frontendPluginManifests); + }); + http.use(router); + http.addAuthPolicy({ + path: `/`, + allow: 'unauthenticated', + }); + + } + }); + } +}); + +exports.default = testPlugin; +//# sourceMappingURL=alpha.cjs.js.map diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js new file mode 100644 index 0000000000..e0fb6a55de --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js @@ -0,0 +1,7 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +const frontendPluginsIndexPath = 'frontend-plugins'; + +exports.frontendPluginsIndexPath = frontendPluginsIndexPath; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json new file mode 100644 index 0000000000..4717963f57 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json @@ -0,0 +1,7 @@ +{ + "name": "private-dep-with-frontend-plugin-index-path", + "version": "0.0.0", + "description": "private dependency of the test backend plugin", + "main": "index.js", + "dependencies": {} +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json new file mode 100644 index 0000000000..4fb0100125 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json @@ -0,0 +1,35 @@ +{ + "name": "plugin-test-backend-dynamic", + "version": "0.0.0", + "description": "A test dynamic backend module that exposes the dynamic frontend plugins to an endpoint.", + "backstage": { + "role": "backend-plugin", + "pluginId": "test", + "pluginPackages": [ + "plugin-test", + "plugin-test-backend" + ] + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "backstage", + "dynamic" + ], + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "default": "./dist/index.cjs.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs.js", + "files": [ + "dist" + ], + "dependencies": { + "private-dep-with-frontend-plugin-index-path": "0.0.0" + }, + "bundleDependencies": true +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json new file mode 100644 index 0000000000..a1f22b0374 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-frontend": { + "type": "object", + "required": [ + "frontendValue" + ], + "properties": { + "frontendValue": { + "type": "string", + "visibility": "frontend" + } + } + } + } +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json new file mode 100644 index 0000000000..99a2949671 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json @@ -0,0 +1,29 @@ +{ + "id": "backstage__plugin_test", + "name": "backstage__plugin_test", + "metaData": { + "name": "backstage__plugin_test", + "type": "app", + "buildInfo": { + "buildVersion": "0.0.0", + "buildName": "@backstage/plugin-test" + }, + "remoteEntry": { + "name": "remoteEntry.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "", + "api": "" + }, + "globalName": "backstage__plugin_test", + "pluginVersion": "0.0.0", + "publicPath": "auto" + }, + "shared": [], + "remotes": [], + "exposes": [] +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js new file mode 100644 index 0000000000..b4554e2a8c --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js @@ -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. + */ + +(function doNothing(){})(); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json new file mode 100644 index 0000000000..06531a2363 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json @@ -0,0 +1,20 @@ +{ + "name": "plugin-test-dynamic", + "version": "0.0.0", + "description": "A test dynamic, module-federation-based, Backstage frontend plugin that does nothing", + "backstage": { + "role": "frontend-dynamic-container", + "pluginId": "test", + "pluginPackages": [ + "plugin-test" + ] + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "backstage", + "dynamic" + ], + "main": "remote-entry.js" +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/.shouldNotBeUsed b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/.shouldNotBeUsed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js new file mode 100644 index 0000000000..05b446cbe6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js @@ -0,0 +1 @@ +throw new Error("False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader"); \ No newline at end of file diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json new file mode 100644 index 0000000000..7b2e68e264 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json @@ -0,0 +1,7 @@ +{ + "name": "@backstage/backend-plugin-api", + "version": "0.0.0", + "description": "dummy backstage package that should be skipped by the ComonJSLoduleLoader", + "main": "index.js", + "dependencies": {} +} diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts new file mode 100644 index 0000000000..daee127edc --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -0,0 +1,306 @@ +/* + * 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 { + startTestBackend, + mockServices, + createMockDirectory, +} from '@backstage/backend-test-utils'; +import { dynamicPluginsFeatureLoader } from './features'; +import { DynamicPlugin, dynamicPluginsServiceRef } from '../manager'; +import path, { resolve as resolvePath } from 'path'; +import { + BackendFeature, + createBackendPlugin, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { CommonJSModuleLoader } from '../loader/CommonJSModuleLoader'; +import * as winston from 'winston'; +import { MESSAGE } from 'triple-beam'; +import { overridePackagePathResolution } from '@backstage/backend-plugin-api/testUtils'; + +async function jestFreeTypescriptAwareModuleLoader( + logger: LoggerService, + dontBootstrap: boolean = false, +) { + const loader = new CommonJSModuleLoader(logger); + (loader as any).module = await loader.load('node:module'); + loader.load(path.resolve(__dirname, '../../../cli/config/nodeTransform.cjs')); + if (dontBootstrap) { + loader.bootstrap = async () => {}; + } + return loader; +} + +class MockedTransport extends winston.transports.Console { + readonly logs: string[] = []; + + public log(info: any, callback: () => void) { + if (!info[MESSAGE]?.includes('info: Plugin initialization ')) { + this.logs.push(info[MESSAGE]); + } + super.log!(info, callback); + } + public logv(info: any, callback: () => void) { + if (!info[MESSAGE]?.includes('info: Plugin initialization ')) { + this.logs.push(info[MESSAGE]); + } + super.log!(info, callback); + } +} + +class DynamicPluginLister { + readonly loadedPlugins: DynamicPlugin[] = []; + feature(): BackendFeature { + // eslint-disable-next-line consistent-this + const that = this; + return createBackendPlugin({ + pluginId: 'dynamicPluginsLister', + register(reg) { + reg.registerInit({ + deps: { + dynamicPlugins: dynamicPluginsServiceRef, + }, + async init({ dynamicPlugins }) { + that.loadedPlugins.push(...dynamicPlugins.plugins(true)); + }, + }); + }, + }); + } +} + +describe('dynamicPluginsFeatureLoader', () => { + const dynamicPluginsRootDirectory = resolvePath( + __dirname, + '__fixtures__/dynamic-plugins-root', + ); + + // A dummy `@backstage/backend-plugin-api` package which throws an error is available inside the test fixtures, + // in a `node_modules` folder which is a sibling of the `dynamic-plugins-root`. + // This test demonstrates how, without the skipping logic implemented in the {@link CommonJSModelLoader}, + // this dummy package would be loaded by the backend dynamic plugins instead of the one of the backstage root. + it('should fail because the model loader is not skipping modules living in unexpected locations.', async () => { + const dynamicPLuginsLister = new DynamicPluginLister(); + const mockedTransport = new MockedTransport(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader(logger, true), + transports: [mockedTransport], + format: winston.format.simple(), + }), + dynamicPLuginsLister.feature(), + ], + }); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: an error occurred while loading dynamic backend plugin 'plugin-test-backend-dynamic' from '.*/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic", + ), + ); + expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([ + { + name: 'plugin-test-backend-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: + 'Error: False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader', + }, + expect.anything(), + ]); + }); + + it('should load and show the 2 dynamic plugins in a list of dynamic plugins returned by a static backend plugin', async () => { + const dynamicPLuginsLister = new DynamicPluginLister(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + dynamicPLuginsLister.feature(), + ], + }); + + expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([ + { + installer: { + kind: 'new', + }, + name: 'plugin-test-backend-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'plugin-test-dynamic', + platform: 'web', + role: 'frontend-dynamic-container', + version: '0.0.0', + }, + ]); + }); + + it('should redact the secret config values of dynamic plugin config schemas in logs', async () => { + const mockedTransport = new MockedTransport(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + 'test-backend': { + secretValue: 'AVerySecretValue', + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + transports: [mockedTransport], + format: winston.format.simple(), + }), + ], + }); + + expect(mockedTransport.logs).toContainEqual( + 'info: Found 1 new secrets in config that will be redacted {"service":"backstage"}', + ); + + expect(mockedTransport.logs).toContainEqual( + 'info: This secret value should be hidden by the dynamic-plugin-aware logger: *** {"service":"backstage"}', + ); + }); + + const mockAppDir = createMockDirectory(); + overridePackagePathResolution({ + packageName: 'app', + path: mockAppDir.path, + }); + + it('should inject frontend config values of dynamic frontend plugin config schemas to the frontend application', async () => { + mockAppDir.setContent({ + 'package.json': '{}', + dist: { + static: {}, + 'index.html.tmpl': '', + '.config-schema.json': ` +{ + "backstageConfigSchemaVersion": 1, + "schemas": [] +} +`, + }, + }); + + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + 'test-frontend': { + frontendValue: 'AFrontendValue', + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + import('@backstage/plugin-app-backend/alpha'), + ], + }); + + await expect( + fetch(`http://localhost:${server.port()}`).then(res => res.text()), + ).resolves.toBe(` + +`); + }); + + it('should access the module federation assets of the frontend plugin through the backend plugin', async () => { + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/api/test/frontend-plugins`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual({ + 'plugin-test-dynamic': `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, + }); + + const manifest = await fetch( + `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, + ); + expect(manifest.ok).toBe(true); + expect(await manifest.json()).toMatchObject({ + exposes: [], + id: 'backstage__plugin_test', + name: 'backstage__plugin_test', + metaData: { + buildInfo: { + buildName: '@backstage/plugin-test', + buildVersion: '0.0.0', + }, + globalName: 'backstage__plugin_test', + name: 'backstage__plugin_test', + pluginVersion: '0.0.0', + publicPath: 'auto', + }, + }); + }); +}); diff --git a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts index 306ecc9255..d101487318 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts @@ -44,6 +44,7 @@ export const dynamicPluginsFrontendSchemas = createBackendModule({ config.getOptionalString('app.packageName') ?? 'app'; const appDistDir = resolvePackagePath(appPackageName, 'dist'); const compiledConfigSchema = await loadCompiledConfigSchema(appDistDir); + // TODO(davidfestal): Add dynamic pliugin config schemas even if the compiled schemas are empty. if (compiledConfigSchema) { configSchemaExtension.setConfigSchema( (await schemas.addDynamicPluginsSchemas(compiledConfigSchema)) diff --git a/yarn.lock b/yarn.lock index 7285cc8a19..c360f2247d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3712,6 +3712,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-app-node": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" @@ -3729,6 +3730,7 @@ __metadata: express: ^4.17.1 fs-extra: ^11.2.0 lodash: ^4.17.21 + triple-beam: ^1.4.1 wait-for-expect: ^3.0.2 winston: ^3.2.1 languageName: unknown