diff --git a/.changeset/bright-eagles-love.md b/.changeset/bright-eagles-love.md new file mode 100644 index 0000000000..eade94f6f8 --- /dev/null +++ b/.changeset/bright-eagles-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-incremental-ingestion': minor +--- + +Added new mechanism to handle deltas in incremental providers diff --git a/.changeset/cyan-deers-walk.md b/.changeset/cyan-deers-walk.md new file mode 100644 index 0000000000..236eb7935b --- /dev/null +++ b/.changeset/cyan-deers-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-node': patch +--- + +Fixed bug caused by recent migration to AWS SDK V3 for techdocs-node. Instead of s3ForcePathStyle, forcePathStyle should be passed. diff --git a/.changeset/friendly-scissors-shop.md b/.changeset/friendly-scissors-shop.md new file mode 100644 index 0000000000..03c67ba491 --- /dev/null +++ b/.changeset/friendly-scissors-shop.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +- **Deprecation** - Deprecated the following exports, please import them directly from `@backstage/plugin-scaffolder-react` instead + + ``` + createScaffolderLayout + ScaffolderLayouts + LayoutOptions + LayoutTemplate + ``` diff --git a/.changeset/gold-lemons-eat.md b/.changeset/gold-lemons-eat.md new file mode 100644 index 0000000000..8d9c0118b1 --- /dev/null +++ b/.changeset/gold-lemons-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Updating the readme with better `csp` instructions diff --git a/.changeset/gold-masks-sleep.md b/.changeset/gold-masks-sleep.md new file mode 100644 index 0000000000..425a1abdc0 --- /dev/null +++ b/.changeset/gold-masks-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Fixed bug in Techdocs reader where a techdocs page with a hash in the URL did not always jump to the document anchor. diff --git a/.changeset/itchy-goats-melt.md b/.changeset/itchy-goats-melt.md new file mode 100644 index 0000000000..d073dd4103 --- /dev/null +++ b/.changeset/itchy-goats-melt.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch +'@backstage/plugin-scaffolder-backend-module-sentry': patch +'@backstage/plugin-scaffolder-backend-module-yeoman': patch +'@backstage/plugin-scaffolder-backend-module-rails': patch +--- + +Internal refactor to use the new `@backstage/plugin-scaffolder-node` package for some functionality diff --git a/.changeset/khaki-toes-care.md b/.changeset/khaki-toes-care.md new file mode 100644 index 0000000000..e119208335 --- /dev/null +++ b/.changeset/khaki-toes-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-common': minor +--- + +**BREAKING**: Renamed misspelled `LimitRangeFetchReponse` to `LimitRangeFetchResponse`. diff --git a/.changeset/lazy-badgers-rule.md b/.changeset/lazy-badgers-rule.md new file mode 100644 index 0000000000..748b9dbadc --- /dev/null +++ b/.changeset/lazy-badgers-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-insights-backend-module-jsonfc': patch +--- + +Surface the cause of the json rules engine diff --git a/.changeset/lazy-badgers-try.md b/.changeset/lazy-badgers-try.md new file mode 100644 index 0000000000..4ffb32304a --- /dev/null +++ b/.changeset/lazy-badgers-try.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +New package that takes over some of the types and functionality from `@backstage/plugin-scaffolder-backend` that are shared with other modules diff --git a/.changeset/lemon-tables-train.md b/.changeset/lemon-tables-train.md new file mode 100644 index 0000000000..80b64bfe53 --- /dev/null +++ b/.changeset/lemon-tables-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +Added support for `externalId` when assuming role in `AwsS3EntityProvider` diff --git a/.changeset/lovely-ladybugs-taste.md b/.changeset/lovely-ladybugs-taste.md new file mode 100644 index 0000000000..24017e4dee --- /dev/null +++ b/.changeset/lovely-ladybugs-taste.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +**Deprecations**: The following are deprecated and should instead be imported from the new package `@backstage/plugin-scaffolder-node`: + +- `ActionContext` +- `createTemplateAction` +- `TaskSecrets` +- `TemplateAction` diff --git a/.changeset/nervous-apricots-whisper.md b/.changeset/nervous-apricots-whisper.md new file mode 100644 index 0000000000..ce71486364 --- /dev/null +++ b/.changeset/nervous-apricots-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Improve view: remove footer overlay on large screen diff --git a/.changeset/nervous-mangos-rhyme.md b/.changeset/nervous-mangos-rhyme.md new file mode 100644 index 0000000000..23e92c65a7 --- /dev/null +++ b/.changeset/nervous-mangos-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +--- + +Extract default transformers to their own file diff --git a/.changeset/perfect-cheetahs-serve.md b/.changeset/perfect-cheetahs-serve.md new file mode 100644 index 0000000000..08f7c1da21 --- /dev/null +++ b/.changeset/perfect-cheetahs-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added the option to overwrite files in the `targetPath` of the `template:fetch` action diff --git a/.changeset/pink-falcons-serve.md b/.changeset/pink-falcons-serve.md new file mode 100644 index 0000000000..8cc6859c25 --- /dev/null +++ b/.changeset/pink-falcons-serve.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-react': minor +--- + +Embed scaffolder workflow in other components diff --git a/.changeset/quick-ladybugs-reply.md b/.changeset/quick-ladybugs-reply.md new file mode 100644 index 0000000000..971f6f635b --- /dev/null +++ b/.changeset/quick-ladybugs-reply.md @@ -0,0 +1,6 @@ +--- +'@techdocs/cli': patch +--- + +Fixed publish command missing awsBucketRootPath option. +Fixed publish command having the gcsBucketRootPath option misconfigured, previously returning a boolean vs a string. diff --git a/.changeset/rare-melons-battle.md b/.changeset/rare-melons-battle.md new file mode 100644 index 0000000000..85fd72c763 --- /dev/null +++ b/.changeset/rare-melons-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add `initialFilter` prop to EntityLifecyclePicker. This allows you to set an initial lifecycle for the catalog. diff --git a/.changeset/shiny-years-tap.md b/.changeset/shiny-years-tap.md new file mode 100644 index 0000000000..d0d0c14c0a --- /dev/null +++ b/.changeset/shiny-years-tap.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-issues': patch +--- + +Updated README.md examples to use correct components and fixed some syntax errors. diff --git a/.changeset/silly-turkeys-hang.md b/.changeset/silly-turkeys-hang.md new file mode 100644 index 0000000000..9f712a43c8 --- /dev/null +++ b/.changeset/silly-turkeys-hang.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-react': patch +--- + +Add `ScaffolderLayouts` to `NextScaffolderPage` diff --git a/.changeset/swift-fishes-smash.md b/.changeset/swift-fishes-smash.md new file mode 100644 index 0000000000..cf3f00129c --- /dev/null +++ b/.changeset/swift-fishes-smash.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +`HttpRouterFactoryOptions.getPath` is now optional as a default value is always provided in the factory. diff --git a/.changeset/twenty-parents-relate.md b/.changeset/twenty-parents-relate.md new file mode 100644 index 0000000000..a883873c16 --- /dev/null +++ b/.changeset/twenty-parents-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +All mock service factories and mock service implementations are now available via the new experimental `mockServices` export. diff --git a/.changeset/young-singers-learn.md b/.changeset/young-singers-learn.md new file mode 100644 index 0000000000..9df5d91fe3 --- /dev/null +++ b/.changeset/young-singers-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-splunk-on-call': patch +--- + +Refactor plugin Card component to not rerender contents unnecessarily. diff --git a/ADOPTERS.md b/ADOPTERS.md index 7ed897f351..965cf06727 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -228,3 +228,6 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/ | [Booking.com](https://www.linkedin.com/company/booking.com/) | [Mesut Yilmazyildirim](https://www.linkedin.com/in/myilmazyildirim) | We are adopting Backstage as the new reliability platform inside the company. We are migrating UIs of our internal developer tools to Backstage for a better user experience. | [Swissquote Bank](https://swissquote.com/company/jobs/open-positions) | [Bruno Rocha](https://www.linkedin.com/in/bruno-rocha1/) | Integrating Backstage as the visualization layer & tactical overview of our services and teams. | [XP Inc.](https://www.linkedin.com/company/xpinc/) | [Gabriel Santos](https://www.linkedin.com/in/gabriel-santos-6bb740a/) | Developer Portal Catalog (components, APIs, resources, users, groups) and organization relations. +| [OVO Energy](https://www.ovoenergy.com/) | [Michael Wizner](https://github.com/mwz), [Dan Laird](https://github.com/dlaird-ovo), [Samantha Betts](https://github.com/sammbetts) | Developer Experience Tool with an aim to to improve processes, boost productivity, finding of information/docs and building tech engagement throughout the business. +| [MusicTribe](https://careers.musictribe.com/) | [Alex Ford](mailto:alex.j.ford@gmail.com), [Tiago Barbosa](https://github.com/t1agob) | We are starting to use Backstage as a developer portal to share API specifications and documentation, to quickly onboard new projects with the software templates, and to help developers discover software through the catalog. + diff --git a/docs/assets/backend-system/architecture-building-blocks.drawio.svg b/docs/assets/backend-system/architecture-building-blocks.drawio.svg new file mode 100644 index 0000000000..0244d461c6 --- /dev/null +++ b/docs/assets/backend-system/architecture-building-blocks.drawio.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + +
+
+
+ Backend +
+
+
+
+ + Backend + +
+
+ + + + +
+
+
+ Services +
+
+
+
+ + Services + +
+
+ + + + + + + + +
+
+
+ Plugins +
+
+
+
+ + Plugins + +
+
+ + + + +
+
+
+ Extension Points +
+
+
+
+ + Extension Poi... + +
+
+ + + + + + + + +
+
+
+ Modules +
+
+
+
+ + Modules + +
+
+ + +
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index d6303ced6f..f9a78bbc71 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -41,7 +41,7 @@ besides the config section above, it also needs to be given one or more callbacks in actual code as well as described below. Add a `providerFactories` entry to the router in -`packages/backend/plugin/auth.ts`. +`packages/backend/src/plugins/auth.ts`. ```ts import { providers } from '@backstage/plugin-auth-backend'; @@ -75,7 +75,10 @@ export default async function createPlugin( // GSuite or similar, based on the IAP token sub/email claims const id = iapToken.email.split('@')[0]; const sub = stringifyEntityRef({ kind: 'User', name: id }); - const ent = [sub, stringifyEntityRef({ kind: 'Group', name: 'team-name' }); + const ent = [ + sub, + stringifyEntityRef({ kind: 'Group', name: 'team-name' }), + ]; return ctx.issueToken({ claims: { sub, ent } }); }, }, diff --git a/docs/backend-system/architecture/01-index.md b/docs/backend-system/architecture/01-index.md new file mode 100644 index 0000000000..e3354112fb --- /dev/null +++ b/docs/backend-system/architecture/01-index.md @@ -0,0 +1,74 @@ +--- +id: index +title: Backend System Architecture +sidebar_label: System Architecture +# prettier-ignore +description: The structure and architecture of the new Backend System and its component parts +--- + +# Overview + +This section introduces the high-level building blocks upon which this new +system is built. These are all concepts that exist in our current system in one +way or another, but they have all been lifted up to be first class concerns in +the new system. + +## Building Blocks + +This section introduces the high-level building blocks upon which this new system is built. Regardless of whether you are setting up your own backstage instance, developing plugins, or extending plugins with new features, it is important to understand these concepts. + +The diagram below provides an overview of the different building blocks, and the other blocks that each of them interact with. + +![backend system building blocks diagram](../../assets/backend-system/architecture-building-blocks.drawio.svg) + +> NOTE: These are all concepts that existed in our old backend system in one way or another, but they have now all been lifted up to be first class concerns. + +### Backend + +This is the backend instance itself, which you can think of as the unit of deployment. It does not have any functionality in and of itself, but is simply responsible for wiring things together. + +It is up to you to decide how many different backends you want to deploy. You can have all features in a single one, or split things out into multiple smaller deployments. All depending on your need to scale and isolate individual features. + +### Plugins + +Plugins provide the actual features, just like in our existing system. They operate completely independently of each other. If plugins want to communicate with each other, they must do so over the wire. There can be no direct communication between plugins through code. Because of this constraint, each plugin can be considered to be its own microservice. + +### Services + +Services provide utilities to help make it simpler to implement plugins, so that each plugin doesn't need to implement everything from scratch. There are both many built-in services, like the ones for logging, database access, and reading configuration, but you can also import third-party services, or create your own. + +Services are also a customization point for individual backend installations. You can both override services with your own implementations, as well as make smaller customizations to existing services. + +### Extension Points + +Many plugins have ways in which you can extend them, for example entity providers for the Catalog, or custom actions for the Scaffolder. These extension patterns are now encoded into Extension Points. + +Extension Points look a little bit like services, since you depended on them just like you would a service. A key difference is that extension points are registered and provided by plugins themselves, based on what customizations each individual plugin wants to expose. + +Extension Points are also exported separately from the plugin instance itself, and a single plugin can also expose multiple different extension points at once. This makes it easier to evolve and deprecated individual Extension Points over time, rather than dealing with a single large API surface. + +### Modules + +Modules use the plugin Extension Points to add new features for plugins. They might for example add an individual Catalog Entity Provider, or one or more Scaffolder Actions. Modules are basically plugins for plugins. + +Each module may only extend a single plugin, and the module must be deployed together with that plugin in the same backend instance. Modules may however only communicate with their plugin through its registered extension points. + +Just like plugins, modules also have access to services and can depend on their own service implementations. They will however share services with the plugin that they extend, there are no module-specific service implementations. + +## Package structure + +A detailed explanation of the package architecture can be found in the +[Backstage Architecture +Overview](../../overview/architecture-overview.md#package-architecture). The +most important packages to consider for this system are `backend`, +`plugin--backend`, `plugin--node`, and +`plugin--backend-module-`. + +- `plugin--backend` houses the implementation of the backend plugins + themselves. +- `plugin--node` houses the extension points and any other utilities + that modules or other plugins might need. +- `plugin--backend-module-` houses the modules that extend + the plugins via the extension points. +- `backend` is the backend itself that wires everything together to something + that you can deploy. diff --git a/docs/backend-system/architecture/02-backends.md b/docs/backend-system/architecture/02-backends.md new file mode 100644 index 0000000000..c73a899863 --- /dev/null +++ b/docs/backend-system/architecture/02-backends.md @@ -0,0 +1,27 @@ +--- +id: backends +title: Backend Instances +sidebar_label: Backend +# prettier-ignore +description: Service APIs for backend plugins +--- + +The new Backstage backend system is being built to help make it simpler to install backend plugins and to keep projects up to date. It also changes the foundation to one that makes it a lot easier to evolve plugins and the system itself with minimal disruption or cause for breaking changes. You can read more about the reasoning in the [original RFC](https://github.com/backstage/backstage/issues/11611). + +One of the goals of the new system was to reduce the code needed for setting up a Backstage backend and installing plugins. This is an example of how you create, add features, and start up your backend in the new system: + +```ts +import { createBackend } from '@backstage/backend-defaults'; +import { catalogPlugin } from '@backstage/plugin-catalog-backend'; + +// Create your backend instance +const backend = createBackend(); + +// Install all desired features +backend.add(catalogPlugin()); + +// Start up the backend +await backend.start(); +``` + +One notable change that helped achieve this much slimmer backend setup is the introduction of a system for dependency injection, which is very similar to the one in the Backstage frontend. diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md new file mode 100644 index 0000000000..27fea27db9 --- /dev/null +++ b/docs/backend-system/architecture/03-services.md @@ -0,0 +1,103 @@ +--- +id: services +title: Backend Service APIs +sidebar_label: Service APIs +# prettier-ignore +description: Service APIs for backend plugins +--- + +## Backend Services + +The default backend provides several [core services](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/coreServices.ts) out of the box which includes access to configuration, logging, databases and more. +Service dependencies are declared using their `ServiceRef`s in the `deps` section of the plugin or module, and the implementations are then forwarded to the `init` method of the plugin or module. + +### Service References + +A `ServiceRef` is a named reference to an interface which are later used to resolve the concrete service implementation. Conceptually this is very similar to `ApiRef`s in the frontend. +Services is what provides common utilities that previously resided in the `PluginEnvironment` such as Config, Logging and Database. + +On startup the backend will make sure that the services are initialized before being passed to the plugin/module that depend on them. +ServiceRefs contain a scope which is used to determine if the serviceFactory creating the service will create a new instance scoped per plugin/module or if it will be shared. `plugin` scoped services will be created once per plugin/module and `root` scoped services will be created once per backend instance. + +#### Defining a Service + +```ts +import { + createServiceFactory, + coreServices, +} from '@backstage/backend-plugin-api'; +import { ExampleImpl } from './ExampleImpl'; + +export interface ExampleApi { + doSomething(): Promise; +} + +export const exampleServiceRef = createServiceRef({ + id: 'example', + scope: 'plugin', // can be 'root' or 'plugin' + + // The defaultFactory is optional to implement but it will be used if no + // other factory is provided to the backend. This allows for the backend + // to provide a default implementation of the service without having to wire + // it beforehand. + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + plugin: coreServices.pluginMetadata, + }, + // This root context method is only available for plugin scoped services. + // It's only called once per backend instance. + // Logger is available at the root context level as it's also a root + // scoped service. + createRootContext({ logger }) { + return new ExampleImplFactory({ logger }); + }, + // Plugin is available as it's a plugin scoped service and will be + // created once per plugin. The logger can be had here too if needed. + // Both this anc the root context can also be async. + factory({ logger, plugin }, rootContext) { + // This block will be executed once for every plugin that depends on + // this service + logger.info('Initializing example service plugin instance'); + return rootContext.forPlugin(plugin.getId()); + }, + }), +}); +``` + +### Overriding Services + +In this example we replace the default root logger service implementation with a custom one that streams logs to GCP. The `rootLoggerServiceRef` has a `'root'` scope, meaning there are no plugin-specific instances of this service. + +```ts +import { + createServiceFactory, + rootLoggerServiceRef, + LoggerService, +} from '@backstage/backend-plugin-api'; + +// This custom implementation would typically live separately from +// the backend setup code, either nearby such as in +// packages/backend/src/services/logger/GoogleCloudLogger.ts +// Or you can let it live in its own library package. +class GoogleCloudLogger implements LoggerService { + static factory = createServiceFactory({ + service: rootLoggerServiceRef, + deps: {}, + factory() { + return new GoogleCloudLogger(); + }, + }); + // custom implementation here ... +} + +// packages/backend/src/index.ts +const backend = createBackend({ + services: [ + // supplies additional or replacement services to the backend + GoogleCloudLogger.factory(), + ], +}); +``` diff --git a/docs/backend-system/architecture/04-plugins.md b/docs/backend-system/architecture/04-plugins.md new file mode 100644 index 0000000000..6f077a0d2d --- /dev/null +++ b/docs/backend-system/architecture/04-plugins.md @@ -0,0 +1,68 @@ +--- +id: plugins +title: Backend Plugins +sidebar_label: Plugins +# prettier-ignore +description: Backend plugins +--- + +## Creating Plugins + +Plugins are created using the `createBackendPlugin` function. All plugins must have an ID and a register method. Plugins may also accept an options object, which can be either optional or required. The options are passed to the second parameter of the register method, and the options type is inferred and forwarded to the returned plugin factory function. + +```ts +import { + configServiceRef, + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +// export type ExamplePluginOptions = { exampleOption: boolean }; +export const examplePlugin = createBackendPlugin({ + // unique id for the plugin + id: 'example', + // It's possible to provide options to the plugin + // register(env, options: ExamplePluginOptions) { + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + }, + // logger is provided by the backend based on the dependency on loggerServiceRef above. + async init({ logger }) { + logger.info('Hello from example plugin'); + }, + }); + }, +}); +``` + +The plugin can then be installed in the backend using the returned plugin factory function: + +```ts +backend.add(examplePlugin()); +``` + +If we wanted our plugin to accept options as well, we'd accept the options as the second parameter of the register method: + +```ts +export const examplePlugin = createBackendPlugin({ + id: 'example', + register(env, options?: { silent?: boolean }) { + env.registerInit({ + deps: { logger: coreServices.logger }, + async init({ logger }) { + if (!options?.silent) { + logger.info('Hello from example plugin'); + } + }, + }); + }, +}); +``` + +Passing the option to the plugin during installation looks like this: + +```ts +backend.add(examplePlugin({ silent: true })); +``` diff --git a/docs/backend-system/architecture/05-extension-points.md b/docs/backend-system/architecture/05-extension-points.md new file mode 100644 index 0000000000..f82fde3561 --- /dev/null +++ b/docs/backend-system/architecture/05-extension-points.md @@ -0,0 +1,82 @@ +--- +id: extension-points +title: Backend Plugin Extension Points +sidebar_label: Extension Points +# prettier-ignore +description: Extension points of backend plugins +--- + +While plugins are able to accept options for lightweight forms of customization and extension, you quickly hit a limit where you need something more powerful to allow users to extend your plugin. For this purpose, the backend system provides a mechanism for plugins to provide extension points, which can be used to expose deeper customizations for your plugin. Extension points are used by modules, which are installed in the backend adjacent to plugins. Modules are covered more in-depth in the [next section](./06-modules.md). + +Extension points are quite similar to services, in that they both encapsulate an interface in a reference object. The key difference is that extension points are registered and provided by plugins themselves, and do not have any factory associated with them. Extension points for a given plugin are also only accessible to modules that extend that same plugin. + +Extension points should always be exported from a plugin node library package, for example `@backstage/plugin-catalog-node`. This is to allow for modules to avoid a direct dependency on the plugin, and make it easier to evolve extension points over time. You can export as many different extension points as you want, just be mindful of the complexity of the API surface. It is however often better to export multiple extension points with few methods, rather than few extension points with many methods, as that tends to be easier to maintain. + +## Defining an Extension Point + +Extension points are created using the `createExtensionPoint` method from `@backstage/backend-plugin-api`. You need to provide the type, as well as an ID. + +```ts +import { createExtensionPoint } from '@backstage/backend-plugin-api'; + +export interface ScaffolderActionsExtensionPoint { + addAction(action: ScaffolderAction): void; +} + +export const scaffolderActionsExtensionPoint = + createExtensionPoint({ + id: 'scaffolder.actions', + }); +``` + +## Registering an Extension Point + +For modules to be able to use your extension point, an implementation of it must be registered by the plugin. This is done using the `registerExtensionPoint` method in the `register` callback of the plugin definition. + +```ts +class ActionsExtension implements ScaffolderActionsExtensionPoint { + addActions(...actions: TemplateAction[]): void { ... } + + getRegisteredActions() { ... } +} + +export const scaffolderPlugin = createBackendPlugin( + { + id: 'scaffolder', + register(env) { + const actionsExtensions = new ActionsExtension(); + env.registerExtensionPoint( + scaffolderActionsExtensionPoint, + actionsExtensions, + ); + + env.registerInit({ + deps: { ... }, + async init({ ... }) { + const actions = actionsExtension.getRegisteredActions(); + + // Use the registered actions when setting up the scaffolder ... + }, + }); + }, + }, +); +``` + +There are a couple of things to note here. The first is that our `ActionsExtension` class both implements the `ScaffolderActionsExtensionPoint` interface, but also has additional public methods. These methods won't be available to the modules that use this extension, but we can use them here in the implementation of our plugin. Note also that the `ActionsExtension` class is _not_ exported to the outside, since it's only for the internal use of this plugin during its setup phase. + +The second is that we create our `ActionsExtension` instance within the `register` method, and then access it directly in our `init` method. This is both safe to do and an intended convenience. All modules that extend our plugin will be completely initialized before our plugin gets initialized, which means that at the point where our `init` method is called, all actions have been added and can be accessed. + +## Extension Point Design + +Designing the extension point interface requires careful consideration. It is a public API surface that your plugin will need to maintain over time. Keep in mind that the installation of modules is an intentional action by the user, meaning you can always design an extension point interface for additions only. For example, there is no need for the `scaffolderActionsExtensionPoint` to support removal of actions, since the user can just uninstall the module that added the action. + +Another pattern that can be used is a type of singleton pattern where the extension point is used to add or override some default behavior. For example, let's say the scaffolder wants to expose a way to customize the execution of template tasks. It wouldn't really make sense to allow multiple modules to each add their own task runners, so instead we use a setter to ensure that only one task runner is installed, throwing an error if any other module tries to install another task runner. + +```ts +interface ScaffolderTaskRunnerExtensionPoint { + setTaskRunner(taskRunner: TaskRunner): void; +} +``` + +If you want to make breaking changes to an extension point that already has some usage, we recommend that you instead deprecate the existing one and create a new one with a different name. You might want to use a completely new name, but you can also suffix the existing one with a version number, for example `scaffolderActionsV2ExtensionPoint`. diff --git a/docs/backend-system/architecture/06-modules.md b/docs/backend-system/architecture/06-modules.md new file mode 100644 index 0000000000..83428c7a26 --- /dev/null +++ b/docs/backend-system/architecture/06-modules.md @@ -0,0 +1,47 @@ +--- +id: modules +title: Plugin Modules +sidebar_label: Modules +# prettier-ignore +description: Modules for backend plugins +--- + +Backend modules are used to extend [plugins](./04-plugins.md) with additional features or change existing behavior. They must always be installed in the same backend instance as the plugin that they extend, and may only extend a single plugin. Modules interact with their target plugin using the [extension points](./05-extension-points.md) registered by the plugin, while also being able to depend on the [services](./03-services.md) of that plugin. + +Both modules and plugins register an `init` method that is called during startup. In order to ensure that modules have registered all their extensions before the plugin starts up, all modules for each plugin are completely initialized before the plugin itself is initialized. In practice this means that all promises returned by each `init` method of the modules need to resolve before the plugin `init` method is called. This also means that it is not possible to further interact with the extension points once the `init` method has resolved. + +A module depends on the extension points exported by the target plugin's library package, for example `@backstage/plugin-catalog-node`, and does not directly declare a dependency on the plugin package itself. This is to avoid a direct dependency and potentially cause duplicate installations of the plugin package, while duplicate installations of library packages should always be supported. + +## A Practical Example + +The following is an example on how to create a module that adds a new processor using the `catalogProcessingExtensionPoint`: + +```ts +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { MyCustomProcessor } from './MyCustomProcessor'; + +export const catalogModuleExampleCustomProcessor = createBackendModule({ + moduleId: 'exampleCustomProcessor', + pluginId: 'catalog', + register(env) { + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + logger: coreServices.logger, + }, + async init({ catalog }) { + catalog.addProcessor(new MyCustomProcessor(logger)); + }, + }); + }, +}); +``` + +Notice that we're placing the extension point we want to interact with in the `deps` option, while also depending on the logger service at the same time. When initializing modules we can depend on both extension points and services interchangeably. You can also depend on multiple extension points at once, in case the implementation of the module requires it. + +It is typically best to keep modules slim and to each only add a single new feature. It is often the case that it is better to create two separate modules rather than one that provides both features. The one limitation here is that modules can not interact with each other and need to be self contained. + +## HTTP Handlers + +Since modules have access to the same services as the plugin they extend, they are also able to register their own HTTP handlers. For more information about the HTTP service, see [core services](../core-services/01-index.md). When registering HTTP handlers, it is important to try to avoid any future conflict with the plugin itself, or other modules. A recommended naming pattern is to register the handlers under the `/modules/` path, where `` is the kebab-case ID of the module, for example `/modules/example-custom-processor/v1/validators`. In a standard backend setup the full path would then be `/api/catalog/modules/example-custom-processor/v1/validators`. diff --git a/docs/backend-system/architecture/07-naming-patterns.md b/docs/backend-system/architecture/07-naming-patterns.md new file mode 100644 index 0000000000..6bd7c412c3 --- /dev/null +++ b/docs/backend-system/architecture/07-naming-patterns.md @@ -0,0 +1,94 @@ +--- +id: naming-patterns +title: Backend System Naming Patterns +sidebar_label: Naming Patterns +# prettier-ignore +description: Naming patterns in the backend system +--- + +These are the naming patterns to adhere to within the backend system. They help us keep exports consistent across packages and make it easier to understand the usage and intent of exports. + +### Plugins + +| Description | Pattern | Examples | +| ----------- | ------------ | ----------------------------------- | +| export | `Plugin` | `catalogPlugin`, `scaffolderPlugin` | +| ID | `''` | `'catalog'`, `'scaffolder'` | + +Example: + +```ts +export const catalogPlugin = createBackendPlugin({ + id: 'catalog', + ... +}) +``` + +### Modules + +| Description | Pattern | Examples | +| ----------- | ---------------------------- | ----------------------------------- | +| export | `Module` | `catalogModuleGithubEntityProvider` | +| ID | `''` | `'githubEntityProvider'` | + +Example: + +```ts +export const catalogModuleGithubEntityProvider = createBackendPlugin({ + pluginId: 'catalog', + moduleId: 'githubEntityProvider', + ... +}) +``` + +### Extensions + +| Description | Pattern | Examples | +| ----------- | -------------------------------- | -------------------------------------- | +| Interface | `ExtensionPoint` | `CatalogProcessingExtensionPoint` | +| Reference | `ExtensionPoint` | `catalogProcessingExtensionPoint` | +| ID | `'.'` | `'catalog.processing'`, `'foo.barBaz'` | + +Example: + +```ts +export interface CatalogProcessingExtensionPoint { + ... +} + +export const catalogProcessingExtensionPoint = createExtensionPoint({ + id: 'catalog.processing', + ... +}) +``` + +### Services + +| Description | Pattern | Examples | +| ----------- | ------------------- | -------------------------------------------------- | +| Interface | `Service` | `LoggerService`, `DatabaseService` | +| Reference | `ServiceRef` | `loggerServiceRef`, `databaseServiceRef` | +| ID | `.` | `'core.rootHttpRouter'`, `'catalog.catalogClient'` | +| Factory | `Factory` | `loggerFactory`, `databaseFactory` | + +Example: + +```ts +export interface CatalogClientService { + ... +} + +export const catalogClientServiceRef = createServiceRef({ + id: 'catalog.catalogClient', + ... +}) + +export const catalogClientFactory = createServiceFactory({ + service: catalogClientServiceRef, + ... +}) +``` + +An exception to the above service reference naming pattern has been made for the all of the core services in the core API. The `@backstage/backend-plugin-api` makes all core service references available via a single `coreServices` collection. Likewise, the `@backstage/backend-test-utils` exports all mock service implementations via a single `mockServices` collection. This means that the table above is slightly misleading, since `loggerServiceRef` and `databaseServiceRef` are instead available as `coreServices.logger` and `coreService.database`. We recommend that plugins avoid this patterns unless they have a very large number of services that they need to export. + +While it is often preferred to prefix root scoped services with `Root`, it is not required. For example, `RootHttpRouterService` and `RootLifecycleService` follow this pattern, but `ConfigService` doesn't and it is a root scoped service. diff --git a/docs/backend-system/building-backends/01-index.md b/docs/backend-system/building-backends/01-index.md new file mode 100644 index 0000000000..e1f8f87964 --- /dev/null +++ b/docs/backend-system/building-backends/01-index.md @@ -0,0 +1,178 @@ +--- +id: index +title: Building Backends +sidebar_label: Overview +# prettier-ignore +description: Building backends using the new backend system +--- + +> NOTE: If you have an existing backend that is not yet using the new backend +> system, see [migrating](./08-migrating.md). + +# Overview + +A minimal Backstage backend is very lightweight. It is a single package with a `package.json` file and a `src/index.ts` file, not counting surrounding tooling and documentation. The package is typically placed within the `packages/backend` folder of a Backstage monorepo, but that is up to you. The backend package is part of any project created with `@backstage/create-app`, so you typically do not need to create it yourself. + +You'll get a brief description here, but for a more in-depth explanation of the role of backends in the backend system, see [the architecture section](../architecture/02-backends.md). + +When you create a new project with `@backstage/create-app`, you'll get a backend package with a `src/index.ts` that looks something like this: + +```ts +import { createBackend } from '@backstage/backend-defaults'; +import { appPlugin } from '@backstage/plugin-app-backend'; +import { catalogPlugin } from '@backstage/plugin-catalog-backend'; +import { scaffolderCatalogModule } from '@backstage/plugin-scaffolder-backend'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder-backend'; + +const backend = createBackend(); + +backend.add(appPlugin()); +backend.add(catalogPlugin()); +backend.add(scaffolderCatalogModule()); +backend.add(scaffolderPlugin()); + +backend.start(); +``` + +There will be a couple more plugins and modules in the initial setup, but the overall layout is the same. + +What we're doing in this file is creating a new backend using `createBackend`, and then installing a collection of different plugins and modules that we want to be part of that backend. Plugins are standalone features, while modules augment existing plugins. Each module can only target a single plugin, and that plugin must also be present in the same backend. Finally, we start up the backend by calling the `start` method. + +## Customization + +Apart from installing existing plugins and modules in the backend, there are a couple of different ways in which you can customize your backend installation. + +### Configuration + +Perhaps the most accessible way is though static configuration, which you can read more about in the documentation for how to [write configuration](../../conf/writing.md). Many different aspects of the backend can be configured, including both the behavior of the backend itself, as well as many plugins or modules. You'll need to refer to the documentation of each plugin or module to see what configuration is available. Also be sure to check out the documentation of the [core services](../core-services/01-index.md), as that also covers how to configure those. + +### Services + +Speaking of services, they are another important point of customization. Services allow you to make deeper and broader customizations of the backend. They are similar to [Utility APIs](../../api/utility-apis.md) in the frontend system, using dependency injection to make common functionality available to plugins and modules. You can read more about services in the [architecture section](../architecture/03-services.md). + +There is a core set of services that must be installed in all backends, for things like logging, database access, serving HTTP, and so on. Luckily, all of these services are installed by default when you use `createBackend` from the `@backstage/backend-defaults` package, which is what you have in a standard setup. + +All of these services can be replaced with your own implementations if you need to customize them. The simplest way to do this is to use the existing service implementations but with additional options. Many of the core services can be customized this way, but not all, as they don't all have meaningful options. + +For example, let's say we want to customize the core configuration service to enable remote configuration loading. That would look something like this: + +```ts +import { configFactory } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + configFactory({ + remote: { reloadIntervalSeconds: 60 }, + }), + ], +}); +``` + +This will make it possible to pass URLs as configuration targets, and those URLs will be polled every 60 seconds for changes. + +There is one exception to the above, which is the built-in `PluginMetadataService` that is provided by the framework and is not possible to override. + +### Custom Service Implementations + +When overriding services you are not limited to the existing implementations, you can also provide your own custom service factories. This will let you globally override services with completely custom implementations, or build on existing implementations to add additional logic. + +To override a service, you provide it in the `services` option just like above, but this time we need to use `createServiceFactory` to create our factory. For example, if you want to replace the default `LoggerService` with your own, it might look like this: + +```ts +const backend = createBackend({ + services: [ + createServiceFactory({ + service: coreServices.logger, + deps: { + rootLogger: coreServices.rootLogger, + plugin: coreServices.pluginMetadata, + config: coreServices.config, + }, + factory({ rootLogger, plugin, config }) { + const labels = readCustomLogLabelsForPlugin(config, plugin); // custom logic + return rootLogger.child(labels); + }, + }), + ], +}); +``` + +The `LoggerService` is responsible for creating a specialized logger instance for each plugin, while the `RootLoggerService` is the actual logging implementation. The default implementation of `LoggerService` will decorate the logger with a `plugin` label that contains the plugin ID. Here in our custom implementation we read out additional labels from the configuration and add those as well. + +This example touches on the fact that services can have different scopes, being either scoped to individual plugins or the root backend instance. To read more about this, see the [architecture section](../architecture/03-services.md). + +## Split Into Multiple Backends + +> NOTE: Splitting into multiple backends is an advanced deployment pattern that requires significant effort and there are not yet many built-in tools in the framework to help you out. Only use this if necessary. + +A more advanced way to deploy Backstage is to split the backend plugins into multiple different backend deployments. Both the [deployment documentation](../../deployment/scaling.md) and [Threat Model](../../overview/threat-model.md#trust-model) explain the benefits of this, so here we'll focus on how to do it. + +To create a separate backend we need to create an additional backend package. This package will be built and deployed separately from your existing backend. There is currently no template to create a backend via `yarn new`, so the quickest way is to copy the new package and modify. The naming is up to you and it depends on how you are splitting things and up. For this example we'll just use a simple suffix. You might end up with a directory structure like this: + +```text +packages/ + backend-a/ + src/ + index.ts + package.json <- "name": "backend-a" + backend-b/ + src/ + index.ts + package.json <- "name": "backend-b" +``` + +You can now trim down the `src/index.ts` files to only include the plugins and modules that you want to be part of that backend. For example, if you want to split out the scaffolder plugin, you might end up with something like this: + +```ts +// packages/backend-a/src/index.ts, imports omitted +const backend = createBackend(); + +backend.add(appPlugin()); +backend.add(catalogPlugin()); +backend.add(scaffolderCatalogModule()); +backend.start(); +``` + +And `backend-b`, don't forget to clean up dependencies in `package.json` as well: + +```ts +// packages/backend-b/src/index.ts, imports omitted +const backend = createBackend(); + +backend.add(scaffolderPlugin()); +backend.start(); +``` + +We've now split the backend into two separate deployments, but we still need to make sure that they can communicate with each other. This is the hard and somewhat tedious part, as Backstage currently doesn't provide an out of the box solution that solves this. You'll need to manually configure the two backends with custom implementations of the `DiscoveryService` and have them return the correct URLs for each other. Likewise, you'll also need to provide a custom implementation of the `DiscoveryApi` in the frontend, unless you surface the two backends via a proxy that handles the routing instead. + +### Shared Environments + +To make it a bit easier to manage multiple backends, it's possible to create a shared environment that can be used across multiple backends. You would typically house it in a separate package that can be referenced by backends in your monorepo, or published to a package registry for broader use. + +A shared environment contains a set of service implementations that should be used across all backends. These services will override the default ones, but if a service is provided directly to the backend, it will override the one in the shared environment. + +A shared environment is defined using `createSharedEnvironment`. In this example we place it in a new and separate package called `backend-env`: + +```ts +// packages/backend-env/src/index.ts +import { createSharedEnvironment } from '@backstage/backend-plugin-api'; +import { customDiscoveryFactory } from './customDiscoveryFactory'; + +export const env = createSharedEnvironment({ + services: [ + customDiscoveryFactory(), // custom DiscoveryService implementation + ], +}); +``` + +And passed on to backends using the `env` option: + +```ts +// packages/backend-b/src/index.ts, imports omitted +import { env } from '@internal/backend-env'; + +const backend = createBackend({ env }); + +backend.add(scaffolderPlugin()); +backend.start(); +``` diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md new file mode 100644 index 0000000000..71e5b52c24 --- /dev/null +++ b/docs/backend-system/building-backends/08-migrating.md @@ -0,0 +1,431 @@ +--- +id: migrating +title: Migrating your Backend to the New Backend System +sidebar_label: Migration Guide +# prettier-ignore +description: How to migrate existing backends to the new backend system +--- + +## Overview + +This section describes how to migrate an existing Backstage backend service +package (typically in `packages/backend`) to use the new backend system. + +One of the main benefits of the new backend system is that it abstracts away the +way that plugins and their dependencies are wired up, leading to a significantly +simplified backend package that rarely if ever needs to change when plugins or +their dependencies evolve. You generally don't have to convert all of your +internal plugins and support classes themselves to the backend system first - +the migration here will mostly deal with wiring and using compatibility wrappers +where possible in the backend package itself. We hope that you will find that +you end up with a much smaller, easier to understand, and easier to maintain +package as a result of these steps, and then being able to [migrate +plugins](../building-plugins-and-modules/08-migrating.md) as a separate +endeavour later. + +## Overall Structure + +Your typical backend package has a few overall component parts: + +- An `index.ts` file that houses all of the creation and wiring together of all + of the plugins and their dependencies +- A `types.ts` file that defines the "environment", i.e. the various + dependencies that get created by the backend and passed down into each plugin +- A `plugins` folder which has one file for each plugin, e.g. + `plugins/catalog.ts` + +The index file has this overall shape: + +```ts +import todo from './plugins/todo'; // repeated for N plugins + +function makeCreateEnv(config: Config) { + return (plugin: string): PluginEnvironment => { + // ... build per-plugin environment + }; +} + +async function main() { + // ... early init + const createEnv = makeCreateEnv(config); + const todoEnv = useHotMemoize(module, () => createEnv('todo')); // repeated for N plugins + const apiRouter = Router(); + apiRouter.use('/todo', await todo(todoEnv)); // repeated for N plugins + // ... wire up and start http server +} + +module.hot?.accept(); +main().catch(...); +``` + +## Migrating the Index File + +This migration will try to leave the `plugins` folder unchanged initially, first +focusing on removing the environment type and reducing the index file to its +bare minimum. Then as a later step, we can reduce the `plugins` folder bit by +bit, replacing those files generally with one-liners in the index file instead. + +Let's start by establishing the basis of your new index file. You may want to +comment out its old contents, or renaming the old file to `index.backup.ts` for +reference and making a new blank one to work on - whichever works best for you. +These are our new blank contents in the index file: + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); +backend.start(); +``` + +Note that the environment builder and the `main` dance are entirely gone. + +We'll also want to add some backend system packages as dependencies. Run the +following command: + +```bash +# from the repository root +yarn add --cwd packages/backend @backstage/backend-defaults @backstage/backend-plugin-api +``` + +You should now be able to start this up with the familiar `yarn workspace +backend start` command locally and seeing some logs scroll by. But it'll just be +a blank service with no real features added. So let's stop it with `Ctrl+C` and +reintroduce some plugins into the mix. + +```diff + import { createBackend } from '@backstage/backend-defaults'; ++import { legacyPlugin } from '@backstage/backend-common'; + + const backend = createBackend(); ++backend.add(legacyPlugin('todo', import('./plugins/todo'))); + backend.start(); +``` + +The `todo` plugin used above is just an example and you may not have it enabled +in your own backend. Feel free to change it to some other plugin that you +actually have in your `plugins` folder, for example +`backend.add(legacyPlugin('catalog', import('./plugins/catalog')))`. + +The `legacyPlugin` helper makes it easy to bridge the gap between the old-style +plugin files and the new backend system. It ensures that the dependencies that +you used to have to declare by hand in your env are gathered behind the scenes, +then passes them into the relevant `createPlugin` export function, and makes +sure that the route handler it returns is passed into the HTTP router with the +given prefix. + +## Handling Custom Environments + +In the simple case, what we did above is sufficient, TypeScript is happy, and +the backend runs with the new feature. If they do, feel free to skip this entire +section, and deleting `types.ts`. + +Sometimes though, type errors can be reported on the newly added line, saying +that parts of the `PluginEnvironment` type do not match. This happens when the +environment was changed from the defaults, perhaps with your own custom +additions. If this is the case in your installation, you still aren't out of +luck - you can build a customized `legacyPlugin` function. + +```diff + import { createBackend } from '@backstage/backend-defaults'; +-import { legacyPlugin } from '@backstage/backend-common'; ++import { makeLegacyPlugin, loggerToWinstonLogger } from '@backstage/backend-common'; ++import { coreServices } from '@backstage/backend-plugin-api'; + ++const legacyPlugin = makeLegacyPlugin( ++ { ++ cache: coreServices.cache, ++ config: coreServices.config, ++ database: coreServices.database, ++ discovery: coreServices.discovery, ++ logger: coreServices.logger, ++ permissions: coreServices.permissions, ++ scheduler: coreServices.scheduler, ++ tokenManager: coreServices.tokenManager, ++ reader: coreServices.urlReader, ++ identity: coreServices.identity, ++ // ... and your own additions ++ }, ++ { ++ logger: log => loggerToWinstonLogger(log), ++ }, ++); + + const backend = createBackend(); + backend.add(legacyPlugin('todo', import('./plugins/todo'))); + backend.start(); +``` + +The first argument to `makeLegacyPlugin` is the mapping from environment keys to +references to actual [backend system services](../architecture/03-services.md). +The second argument allows you to "tweak" the types of those services to +something more fitting to your env. For example, you'll see that the logger +service API type was changed from the raw Winston logger of old, to a different, +custom API, so we use a helper function to transform that particular one. + +To make additions as mentioned above to the environment, you will start to get +into the weeds of how the backend system wiring works. You'll need to have a +service reference and a service factory that performs the actual creation of +your service. Please see [the services +article](../architecture/03-services.md#defining-a-service) to learn how to +create a service ref and its default factory. You can place that code directly +in the index file for now if you want, or near the actual implementation class +in question. + +In this example, we'll assume that your added environment field is named +`example`, and the created ref is named `exampleServiceRef`. + +```diff ++import { exampleServiceRef } from ''; // if the definition is elsewhere + + const legacyPlugin = makeLegacyPlugin( + { + // ... the above core services still go here ++ example: exampleServiceRef + }, + { + logger: log => loggerToWinstonLogger(log), + }, + ); +``` + +After this, your backend will know how to instantiate your thing on demand and +place it in the legacy plugin environment. + +> NOTE: If you happen to be dealing with a service ref that does NOT have a +> default implementation, but rather has a separate service factory, then you +> will also need to import that factory and pass it to the `services` array +> argument of `createBackend`. + +## Cleaning Up the Plugins Folder + +For plugins that are private and your own, you can follow a [dedicated migration +guide](../building-plugins-and-modules/08-migrating.md) as you see fit, at a +later time. + +For third party backend plugins, in particular the larger core plugins that are +maintained by the Backstage maintainers, you may find that they have already +been migrated to the new backend system. This section describes some specific +such migrations you can make. + +> NOTE: For each of these, note that your backend still needs to have a +> dependency (e.g. in `packages/backend/package.json`) to those plugin packages, +> and they still need to be configured properly in your app-config. Those +> mechanisms still work just the same as they used to in the old backend system. + +### The App Plugin + +The app backend plugin that serves the frontend from the backend can trivially +be used in its new form. + +```diff + // packages/backend/src/index.ts ++import { appPlugin } from '@backstage/plugin-app-backend'; + + const backend = createBackend(); ++backend.add(appPlugin({ appPackageName: 'app' })); +``` + +This is an example of how options can be passed into some backend plugins. The +app plugin specifically needs to know the name of the package that holds the +frontend code. This is the `"name"` field in that package's `package.json`, +typically found in your `packages/app` folder. By default it's just plain "app". + +You should be able to delete the `plugins/app.ts` file at this point. + +### The Catalog Plugin + +A basic installation of the catalog plugin looks as follows. + +```diff + // packages/backend/src/index.ts ++import { catalogPlugin } from '@backstage/plugin-catalog-backend'; ++import { scaffolderCatalogModule } from '@backstage/plugin-scaffolder-backend'; + + const backend = createBackend(); ++backend.add(catalogPlugin()); ++backend.add(scaffolderCatalogModule()); +``` + +Note that this also installs a module from the scaffolder, namely the one which +enables the use of the `Template` kind. In the unlikely event that you do not +use templates at all, you can remove those lines. + +If you have other customizations made to `plugins/catalog.ts`, such as adding +custom processors or entity providers, read on. Otherwise, you should be able to +just delete that file at this point. + +You will use the [extension points](../architecture/05-extension-points.md) +mechanism to extend or tweak the functionality of the plugin. To do that, +you'll make your own bespoke [module](../architecture/06-modules.md) which +depends on the appropriate extension point and interacts with it. + +```diff + // packages/backend/src/index.ts ++import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; ++import { createBackendModule } from '@backstage/backend-plugin-api'; + ++const catalogExtensionsModule = createBackendModule({ ++ pluginId: 'catalog', // name of the plugin that the module is targeting ++ moduleId: 'extensions', // you can choose this one freely ++ register(env) { ++ env.registerInit({ ++ deps: { ++ catalog: catalogProcessingExtensionPoint, ++ // ... and other dependencies as needed ++ }, ++ init({ catalog /* ..., other dependencies */ }) { ++ // Here you have the opportunity to interact with the extension ++ // point before the plugin itself gets instantiated ++ catalog.addEntityProvider(new MyEntityProvider()); // just an example ++ catalog.addProcessor(new MyProcessor()); // just an example ++ }, ++ }); ++ }, ++}); + + const backend = createBackend(); + backend.add(catalogPlugin()); + backend.add(scaffolderCatalogModule()); ++backend.add(catalogExtensionsModule()); +``` + +This also requires that you have a dependency on the corresponding node package, +if you didn't already have one. + +```bash +# from the repository root +yarn add --cwd packages/backend @backstage/plugin-catalog-node +``` + +Here we've placed the module directly in the backend index file just to get +going easily, but feel free to move it out to where it fits best. As you migrate +your entire plugin flora to the new backend system, you will probably make more +and more of these modules as "first class" things, living right next to the +implementations that they represent, and being exported from there. + +### The Events Plugin + +A basic installation of the events plugin looks as follows. + +```diff + // packages/backend/src/index.ts ++import { eventsPlugin } from '@backstage/plugin-events-backend'; + + const backend = createBackend(); ++backend.add(eventsPlugin()); +``` + +If you have other customizations made to `plugins/events.ts`, such as adding +custom subscribers, read on. Otherwise, you should be able to just delete that +file at this point. + +You will use the [extension points](../architecture/05-extension-points.md) +mechanism to extend or tweak the functionality of the plugin. To do that, +you'll make your own bespoke [module](../architecture/06-modules.md) which +depends on the appropriate extension point and interacts with it. + +```diff + // packages/backend/src/index.ts ++import { eventsExtensionPoint } from '@backstage/plugin-events-node'; ++import { createBackendModule } from '@backstage/backend-plugin-api'; + ++const eventsExtensionsModule = createBackendModule({ ++ pluginId: 'events', // name of the plugin that the module is targeting ++ moduleId: 'extensions', // you can choose this one freely ++ register(env) { ++ env.registerInit({ ++ deps: { ++ events: eventsExtensionPoint, ++ // ... and other dependencies as needed ++ }, ++ init({ events /* ..., other dependencies */ }) { ++ // Here you have the opportunity to interact with the extension ++ // point before the plugin itself gets instantiated ++ events.addSubscribers(new MySubscriber()); // just an example ++ }, ++ }); ++ }, ++}); + + const backend = createBackend(); + backend.add(eventsPlugin()); ++backend.add(eventsExtensionsModule()); +``` + +This also requires that you have a dependency on the corresponding node package, +if you didn't already have one. + +```bash +# from the repository root +yarn add --cwd packages/backend @backstage/plugin-events-node +``` + +Here we've placed the module directly in the backend index file just to get +going easily, but feel free to move it out to where it fits best. As you migrate +your entire plugin flora to the new backend system, you will probably make more +and more of these modules as "first class" things, living right next to the +implementations that they represent, and being exported from there. + +### The Scaffolder Plugin + +A basic installation of the scaffolder plugin looks as follows. + +```diff + // packages/backend/src/index.ts ++import { scaffolderPlugin } from '@backstage/plugin-scaffolder-backend'; + + const backend = createBackend(); ++backend.add(scaffolderPlugin()); +``` + +If you have other customizations made to `plugins/scaffolder.ts`, such as adding +custom actions, read on. Otherwise, you should be able to just delete that file +at this point. + +You will use the [extension points](../architecture/05-extension-points.md) +mechanism to extend or tweak the functionality of the plugin. To do that, +you'll make your own bespoke [module](../architecture/06-modules.md) which +depends on the appropriate extension point and interacts with it. + +```diff + // packages/backend/src/index.ts ++import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node'; ++import { createBackendModule } from '@backstage/backend-plugin-api'; + ++const scaffolderExtensionsModule = createBackendModule({ ++ pluginId: 'scaffolder', // name of the plugin that the module is targeting ++ moduleId: 'extensions', // you can choose this one freely ++ register(env) { ++ env.registerInit({ ++ deps: { ++ scaffolder: scaffolderActionsExtensionPoint, ++ // ... and other dependencies as needed ++ }, ++ init({ scaffolder /* ..., other dependencies */ }) { ++ // Here you have the opportunity to interact with the extension ++ // point before the plugin itself gets instantiated ++ scaffolder.addActions(new MyAction()); // just an example ++ }, ++ }); ++ }, ++}); + + const backend = createBackend(); + backend.add(scaffolderPlugin()); ++backend.add(scaffolderExtensionsModule()); +``` + +This also requires that you have a dependency on the corresponding node package, +if you didn't already have one. + +```bash +# from the repository root +yarn add --cwd packages/backend @backstage/plugin-scaffolder-node +``` + +Here we've placed the module directly in the backend index file just to get +going easily, but feel free to move it out to where it fits best. As you migrate +your entire plugin flora to the new backend system, you will probably make more +and more of these modules as "first class" things, living right next to the +implementations that they represent, and being exported from there. diff --git a/docs/backend-system/building-plugins-and-modules/01-index.md b/docs/backend-system/building-plugins-and-modules/01-index.md new file mode 100644 index 0000000000..fff82286ad --- /dev/null +++ b/docs/backend-system/building-plugins-and-modules/01-index.md @@ -0,0 +1,12 @@ +--- +id: index +title: Building Backend Plugins and Modules +sidebar_label: Overview +# prettier-ignore +description: Building backend plugins and modules using the new backend system +--- + +> NOTE: If you have an existing backend and/or backend plugins that are not yet +> using the new backend system, see [migrating](./08-migrating.md). + +# Overview diff --git a/docs/backend-system/building-plugins-and-modules/02-testing.md b/docs/backend-system/building-plugins-and-modules/02-testing.md new file mode 100644 index 0000000000..3b19a7cfc8 --- /dev/null +++ b/docs/backend-system/building-plugins-and-modules/02-testing.md @@ -0,0 +1,170 @@ +--- +id: testing +title: Testing Backend Plugins and Modules +sidebar_label: Testing +# prettier-ignore +description: Learn how to test your backend plugins and modules +--- + +Utilities for testing backend plugins and modules are available in +`@backstage/backend-test-utils`. This section describes those facilities. + +## Testing Backend Plugins and Modules + +To facilitate testing of backend plugins and modules, the +`@backstage/backend-test-utils` package provides a `startTestBackend` function +which starts up an entire backend harness, complete with a number of mock +services. You can then provide overrides for services whose behavior you need to +adjust for the test run. The function also accepts a number of _features_ (a +collective term for backend [plugins](../architecture/04-plugins.md) and +[modules](../architecture/06-modules.md)), that are the subjects of the test. + +The function returns an HTTP server instance which can be used together with +e.g. `supertest` to easily test the actual REST service surfaces of plugins who +register routes with [the HTTP router service +API](../core-services/01-index.md). + +```ts +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import request from 'supertest'; +import { myPlugin } from './plugin.ts'; + +describe('myPlugin', () => { + it('can serve values from config', async () => { + const fakeConfig = { myPlugin: { value: 7 } }; + + const { server } = await startTestBackend({ + features: [myPlugin()], + services: [mockServices.config.factory({ data: fakeConfig })], + }); + + const response = await request(server).get('/api/example/get-value'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ value: 7 }); + }); +}); +``` + +This example shows how to access the mock service factories and +pass options to them, which will override the default mock services. + +The returned server also has a `port()` method which returns the dynamically +bound listening port. You can use this to perform lower level network +interactions with the running test service. + +## Testing Remote Service Interactions + +If your backend plugin or service interacts with external services using HTTP +calls, we recommend leveraging the `msw` package to intercept actual outgoing +requests and return mock responses. This lets you stub out remote services +rather than the local clients, leading to more thorough and robust tests. You +can read more about how it works [in their documentation](https://mswjs.io/). + +The `@backstage/backend-test-utils` package exports a `setupRequestMockHandlers` +function which ensures that the correct `jest` lifecycle hooks are invoked to +set up and tear down your `msw` instance, and enables the option that completely +rejects requests that don't match one of your mock rules. This ensures that your +tests cannot accidentally leak traffic into production from tests. + +Example: + +```ts +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +describe('read from remote', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('should auth and read successfully', async () => { + expect.assertions(1); + + worker.use( + rest.get('https://remote-server.com/api/v3/foo', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe('Bearer fake'); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.body(JSON.stringify({ value: 7 })), + ); + }), + ); + + // exercise your plugin or service as usual, with real clients + }); +}); +``` + +## Testing Database Interactions + +The `@backstage/backend-test-utils` package includes facilities for testing your +plugins' interactions with databases, including spinning up `testcontainers` +powered Docker images with real database engines to connect to. + +The base setup for such a test could look as follows: + +```ts +// MyDatabaseClass.test.ts +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { + MyDatabaseClass, + applyDatabaseMigrations, + type FooTableRow, +} from './MyDatabaseClass'; + +describe('MyDatabaseClass', () => { + // Change this to the set of constants that you actually actively intend to + // support. This create call must be made inside a describe block. Make sure + // to create only one TestDatabases instance per file, since spinning up + // "physical" databases to test against is much costlier than creating the + // "logical" databases within them that the individual tests use. + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + // Just an example of how to conveniently bundle up the setup code + async function createSubject(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const subject = new MyDatabaseClass({ database: knex }); + await subject.runMigrations(); + return { knex, subject }; + } + + describe('foo', () => { + // Easily run the exact same test onto all supported databases + it.each(databases.eachSupportedId())( + 'should run foo on %p', + async databaseId => { + const { knex, subject } = await createSubject(databaseId); + // raw knex is available for underlying manipulation + await knex('foo').insert({ value: 2 }); + // drive your system under test as usual + await expect(subject.foos()).resolves.toEqual([{ value: 2 }]); + }); + }); +``` + +If you want to pass the test database instance into backend plugins or services, +you can supply it in the form of a mock instance of `coreServices.database` to +your test database. + +```ts +const { knex, subject } = await createSubject(databaseId); +const { server } = await startTestBackend({ + features: [myPlugin()], + services: [[coreServices.database, { getClient: async () => knex }]], +}); +``` + +When running locally, the tests only run against SQLite for the sake of speed. +When the `CI` environment variable is set, all given database engines are used. + +If you do not want or are unable to use docker based database engines, e.g. if +your CI environment is able to supply databases natively, the `TestDatabases` +support custom connection strings through the use of environment variables that +it'll take into account when present. + +- `BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING` +- `BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING` +- `BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING` diff --git a/docs/backend-system/building-plugins-and-modules/08-migrating.md b/docs/backend-system/building-plugins-and-modules/08-migrating.md new file mode 100644 index 0000000000..ec003a6fec --- /dev/null +++ b/docs/backend-system/building-plugins-and-modules/08-migrating.md @@ -0,0 +1,234 @@ +--- +id: migrating +title: Migrating your Backend Plugin to the New Backend System +sidebar_label: Migration Guide +# prettier-ignore +description: How to migrate existing backend plugins to the new backend system +--- + +Migrating an existing backend plugin to the new backend system is fairly straightforward. The process is similar across the majority of plugins which just return a `Router` that is then wired up in the `index.ts` file of your backend. The primary thing that we need to do is to make sure that the dependencies that are required by the plugin are available, and then registering the router with the HTTP router service. + +Let's look at an example of migrating the Kubernetes backend plugin. In the existing (old) system, the kubernetes backend is structured like this: + +```ts +// @backstage/plugin-kubernetes-backend/src/service/router.ts + +import { KubernetesBuilder } from './KubernetesBuilder'; +export interface RouterOptions { + logger: Logger; + config: Config; + catalogApi: CatalogApi; + clusterSupplier?: KubernetesClustersSupplier; + discovery: PluginEndpointDiscovery; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { router } = await KubernetesBuilder.createBuilder(options) + .setClusterSupplier(options.clusterSupplier) + .build(); + return router; +} +``` + +We can re-use the `router` created by the `KubernetesBuilder` in the new backend system. We only need to make sure that the dependencies specified in `RouterOptions` above are available. All of them are part of the `coreServices` which makes migration easy. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { Router } from 'express'; +import { KubernetesBuilder } from './KubernetesBuilder'; + +export const kubernetesPlugin = createBackendPlugin({ + id: 'kubernetes', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + config: coreServices.config, + catalogApi: catalogServiceRef, + discovery: coreServices.discovery, + // The http router service is used to register the router created by the KubernetesBuilder. + http: coreServices.httpRouter, + }, + async init({ config, logger, catalogApi, discovery, http }) { + const { router } = await KubernetesBuilder.createBuilder({ + config, + logger, + catalogApi, + discovery, + }).build(); + + // We register the router with the http service. + http.use(router); + }, + }); + }, +}); +``` + +Done! Users of this plugin are now able to import the `kubernetesPlugin` and register it in their backend using + +```ts +// packages/backend/src/index.ts +import { kubernetesPlugin } from '@backstage/plugin-kubernetes-backend'; +backend.add(kubernetesPlugin); +``` + +There's one thing missing that those sharp eyed readers might have noticed: the `clusterSupplier` option is missing from the original plugin. Let's add it and discuss the alternatives. + +One alternative is to pass the `ClusterSupplier` in as options to the plugin, which is quick and easy but not very flexible, and also hard to evolve without introducing breaking changes as it changes the public API for the plugin. Having complex types passed in directly to the plugin also clutters the backend setup code and makes it harder to read. + +Options are primarily used for simple configuration values that are not complex types. In this case we want to allow users to register their own `ClusterSupplier` implementations to the plugin. This is where the new backend system's [extension points](../architecture/05-extension-points.md) come in handy, but let's look at doing this with options first. + +```ts +/* omitted imports but they remain the same as above */ + +export interface KubernetesOptions { + clusterSupplier?: KubernetesClustersSupplier; +} + +const kubernetesPlugin = createBackendPlugin((options: KubernetesOptions) => ({ + id: 'kubernetes', + register(env) { + env.registerInit({ + deps: { + /* omitted dependencies but they remain the same as above */ + }, + async init({ config, logger, catalogApi, discovery, http }) { + const { router } = await KubernetesBuilder.createBuilder({ + config, + logger, + catalogApi, + discovery, + }) + .setClusterSupplier(options.clusterSupplier) + .build(); + http.use(router); + }, + }); + }, +})); +``` + +The above would allow users to specify their own `ClusterSupplier` implementation to the plugin like this: + +```ts +backend.add( + kubernetesPlugin({ clusterSupplier: new MyCustomClusterSupplier() }), +); +``` + +Just to echo what was said above, this is not a very flexible solution and will for example be problematic to keep backwards compatible if we start evolving the options to for example accept multiple suppliers or tweak the `ClusterSupplier` interface. + +The new [extension points](../architecture/05-extension-points.md) API allows [modules](../architecture/06-modules.md) to add functionality into the backend plugin itself, in this case an additional `ClusterSupplier`. + +The kubernetes backend plugin only supports one `ClusterSupplier` at this time but let's look at how we could add support for multiple suppliers using extension points. This allows users to install several modules that add their own `ClusterSupplier` implementations to the plugin like this: + +```ts +backend.add(kubernetesPlugin()); +backend.add(kubernetesGoogleContainerEngineClusterSupplier()); +backend.add(kubernetesElasticContainerEngine()); +``` + +Now let's look at how to implement this with extension points. First we need to define the extension point itself. As the extension point will be used by other modules, it's common practice to export these from a shared package so that they can be imported by other modules and plugins. + +We'll go ahead and create a `@backstage/plugin-kubernetes-node` package for this and from there we'll export the extension point. + +```ts +import { createExtensionPoint } from '@backstage/backend-plugin-api'; + +export interface KubernetesClusterSupplierExtensionPoint { + addClusterSupplier(supplier: KubernetesClustersSupplier): void; +} + +/** + * An extension point that allows other plugins to add cluster suppliers. + * @public + */ +export const kubernetesClustersSupplierExtensionPoint = + createExtensionPoint({ + id: 'kubernetes.cluster-supplier', + }); +``` + +Now we can use this extension point in the kubernetes backend plugin to register the extension point for modules to use. + +```ts +import { kubernetesClustersSupplierExtensionPoint, KubernetesClusterSupplierExtensionPoint } from '@backstage/plugin-kubernetes-node'; + +// Our internal implementation of the extension point, should not be exported. +class ClusterSupplier implements KubernetesClusterSupplierExtensionPoint { + private clusterSuppliers: KubernetesClustersSupplier | undefined; + + // This method is private and only used internally to retrieve the registered supplier. + getClusterSupplier() { + return this.clusterSuppliers; + } + + addClusterSupplier(supplier: KubernetesClustersSupplier) { + // We can remove this check once the plugin support multiple suppliers. + if(this.clusterSuppliers) { + throw new Error('Multiple Kubernetes cluster suppliers is not supported at this time'); + } + this.clusterSuppliers = supplier; + } +} + +export const kubernetesPlugin = createBackendPlugin({ + id: 'kubernetes', + register(env) { + const extensionPoint = new ClusterSupplier(); + // We register the extension point with the backend, which allows modules to + // register their own ClusterSupplier. + env.registerExtensionPoint( + kubernetesClustersSupplierExtensionPoint, + extensionPoint, + ); + + env.registerInit({ + deps: { + ... omitted ... + }, + async init({ config, logger, catalogApi, discovery, http }) { + const { router } = await KubernetesBuilder.createBuilder({ + config, + logger, + catalogApi, + discovery, + }) + // We pass in the registered supplier from the extension point. + .setClusterSupplier(extensionPoint.getClusterSupplier()) + .build(); + http.use(router); + }, + }); + }, +}); +``` + +And that's it! Modules can now be built that add clusters into to the kubernetes backend plugin, here's an example of a module that adds a `GoogleContainerEngineSupplier` to the kubernetes backend. + +```ts +import { kubernetesClustersSupplierExtensionPoint } from '@backstage/plugin-kubernetes-node'; + +export const kubernetesGoogleContainerEngineClusterSupplier = + createBackendModule({ + pluginId: 'kubernetes', + moduleId: 'gke.supplier', + register(env) { + env.registerInit({ + deps: { + supplier: kubernetesClustersSupplierExtensionPoint, + }, + async init({ supplier }) { + supplier.addClusterSupplier(new GoogleContainerEngineSupplier()); + }, + }); + }, + }); +``` diff --git a/docs/backend-system/core-services/01-index.md b/docs/backend-system/core-services/01-index.md new file mode 100644 index 0000000000..4ef9d75bde --- /dev/null +++ b/docs/backend-system/core-services/01-index.md @@ -0,0 +1,674 @@ +--- +id: index +title: Core Backend Service APIs +sidebar_label: Core Services +# prettier-ignore +description: Core backend service APIs +--- + +The default backend provides several [core services](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/coreServices.ts) out of the box which includes access to configuration, logging, URL Readers, databases and more. + +All core services are available through the `coreServices` namespace in the `@backstage/backend-plugin-api` package. + +```ts +import { coreServices } from '@backstage/backend-plugin-api'; +``` + +## HTTP Router Service + +One of the most common services is the HTTP router service which is used to expose HTTP endpoints for other plugins to consume. + +### Using the service + +The following example shows how to register a HTTP router for the `example` plugin. +This single route will be available at the `/api/example/hello` path. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { Router } from 'express'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { http: coreServices.httpRouter }, + async init({ http }) { + const router = Router(); + router.get('/hello', (_req, res) => { + res.status(200).json({ hello: 'world' }); + }); + // Registers the router at the /api/example path + http.use(router); + }, + }); + }, +}); +``` + +### Configuring the service + +There's additional configuration that you can optionally pass to setup the `httpRouter` core service. + +- `getPath` - Can be used to generate a path for each plugin. Currently defaults to `/api/${pluginId}` + +You can configure these additional options by adding an override for the core service when calling `createBackend` like follows: + +```ts +import { httpRouterFactory } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + httpRouterFactory({ + getPath: (pluginId: string) => `/plugins/${pluginId}`, + }), + ], +}); +``` + +## Root HTTP Router + +The root HTTP router is a service that allows you to register routes on the root of the backend service. This is useful for things like health checks, or other routes that you want to expose on the root of the backend service. It is used as the base router that backs the `httpRouter` service. Most likely you won't need to use this service directly, but rather use the `httpRouter` service. + +### Using the service + +The following example shows how to get the root HTTP router service in your `example` backend plugin to register a health check route. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { Router } from 'express'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + rootHttpRouter: coreServices.rootHttpRouter, + }, + async init({ rootHttpRouter }) { + const router = Router(); + router.get('/health', (request, response) => { + response.send('OK'); + }); + + rootHttpRouter.use(router); + }, + }); + }, +}); +``` + +### Configuring the service + +There's additional options that you can pass to configure the root HTTP Router service. These options are passed when you call `createBackend`. + +- `indexPath` - optional path to forward all unmatched requests to. Defaults to `/api/app` which is the `app-backend` plugin responsible for serving the frontend application through the backend. + +- `configure` - this is an optional function that you can use to configure the `express` instance. This is useful if you want to add your own middleware to the root router, such as logging, or other things that you want to do before the request is handled by the backend. It's also useful to override the order in which middleware is applied. + +You can configure the root HTTP Router service by passing the options to the `createBackend` function. + +```ts +import { rootHttpRouterFactory } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + rootHttpRouterFactory({ + configure: ({ app, middleware, routes, config, logger, lifecycle }) => { + // the built in middleware is provided through an option in the configure function + app.use(middleware.helmet()); + app.use(middleware.cors()); + app.use(middleware.compression()); + + // you can add you your own middleware in here + app.use(custom.logging()); + + // here the routes that are registered by other plugins + app.use(routes); + + // some other middleware that comes after the other routes + app.use(middleware.notFound()); + app.use(middleware.error()); + }, + }), + ], +}); +``` + +## Config + +This service allows you to read configuration values out of your `app-config` YAML files. + +### Using the service + +The following example shows how you can use the default config service to be able to get a config value, and then log it to the console. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + log: coreServices.logger, + config: coreServices.config, + }, + async init({ log, config }) { + const baseUrl = config.getString('backend.baseUrl'); + log.warn(`The backend is running at ${baseUrl}`); + }, + }); + }, +}); +``` + +### Configuring the service + +There's additional configuration that you can optionally pass to setup the `config` core service. + +- `argv` - Override the arguments that are passed to the config loader, instead of using `process.argv` +- `remote` - Configure remote configuration loading + +You can configure these additional options by adding an override for the core service when calling `createBackend` like follows: + +```ts +import { configFactory } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + configFactory({ + argv: [ + '--config', + '/backstage/app-config.development.yaml', + '--config', + '/backstage/app-config.yaml', + ], + remote: { reloadIntervalSeconds: 60 }, + }), + ], +}); +``` + +## Logging + +This service allows plugins to output logging information. There are actually two logger services: a root logger, and a plugin logger which is bound to individual plugins, so that you will get nice messages with the plugin ID referenced in the log lines. + +### Using the service + +The following example shows how to get the logger in your `example` backend plugin and create a warning message that will be printed nicely to the console. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + log: coreServices.logger, + }, + async init({ log }) { + log.warn("Here's a nice log line that's a warning!"); + }, + }); + }, +}); +``` + +### Root Logger + +The root logger is the logger that is used by other root services. It's where the implementation lies for creating child loggers around the backstage ecosystem including child loggers for plugins with the correct metadata and annotations. + +If you want to override the implementation for logging across all of the backend, this is the service that you should override. + +### Configuring the service + +The following example is how you can override the root logger service to add additional metadata to all log lines. + +```ts +import { coreServices } from '@backstage/backend-plugin-api'; +import { WinstonLogger } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + createServiceFactory({ + service: coreServices.rootLogger, + deps: { + config: coreServices.config, + }, + async factory({ config }) { + const logger = WinstonLogger.create({ + meta: { + service: 'backstage', + // here's some additional information that is not part of the + // original implementation + podName: 'myk8spod', + }, + level: process.env.LOG_LEVEL || 'info', + format: + process.env.NODE_ENV === 'production' + ? format.json() + : WinstonLogger.colorFormat(), + transports: [new transports.Console()], + }); + + return logger; + }, + }), + ], +}); +``` + +## Cache + +This service lets your plugin interact with a cache. It is bound to your plugin too, so that you will only set and get values in your plugin's private namespace. + +### Using the service + +The following example shows how to get a cache client in your `example` backend plugin and setting and getting values from the cache. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + cache: coreServices.cache, + }, + async init({ cache }) { + const { key, value } = { key: 'test:key', value: 'bob' }; + await cache.set(key, value, { ttl: 1000 }); + + // .. some other stuff. + + await cache.get(key); // 'bob' + }, + }); + }, +}); +``` + +## Database + +This service lets your plugins get a `knex` client hooked up to a database which is configured in your `app-config` YAML files, for your persistence needs. + +If there's no config provided in `backend.database` then you will automatically get a simple in-memory SQLite 3 database for your plugin whose contents will be lost when the service restarts. + +This service is scoped per plugin too, so that table names do not conflict across plugins. + +### Using the service + +The following example shows how to get access to the database service in your `example` backend plugin and getting a client for interacting with the database. It also runs some migrations from a certain directory for your plugin. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { resolvePackagePath } from '@backstage/backend-common'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + database: coreServices.database, + }, + async init({ database }) { + const client = await database.getClient(); + const migrationsDir = resolvePackagePath( + '@internal/my-plugin', + 'migrations', + ); + if (!database.migrations?.skip) { + await client.migrate.latest({ + directory: migrationsDir, + }); + } + }, + }); + }, +}); +``` + +## Discovery + +When building plugins, you might find that you will need to look up another plugin's base URL to be able to communicate with it. This could be for example an HTTP route or some `ws` protocol URL. For this we have a discovery service which can provide both internal and external base URLs for a given a plugin ID. + +### Using the service + +The following example shows how to get the discovery service in your `example` backend plugin and making a request to both the internal and external base URLs for the `derp` plugin. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { fetch } from 'node-fetch'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + discovery: coreServices.discovery, + }, + async init({ discovery }) { + const url = await discoverty.getBaseUrl('derp'); // can also use discovery.getBaseUrl to retrieve external URL + const response = await fetch(`${url}/hello`); + }, + }); + }, +}); +``` + +## Identity + +When working with backend plugins, you might find that you will need to interact with the `auth-backend` plugin to both authenticate backstage tokens, and to deconstruct them to get the user's entity ref and/or ownership claims out of them. + +### Using the service + +The following example shows how to get the identity service in your `example` backend plugin and retrieve the user's entity ref and ownership claims for the incoming request. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { Router } from 'express'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + identity: coreServices.identity, + http: coreServices.httpRouter, + }, + async init({ http, identity }) { + const router = Router(); + router.get('/test-me', (request, response) => { + // use the identity service to pull out the header from the request and get the user + const { + identity: { userEntityRef, ownershipEntityRefs }, + } = await identity.getIdentity({ + request, + }); + + // send the decoded and validated things back to the user + response.json({ + userEntityRef, + ownershipEntityRefs, + }); + }); + + http.use(router); + }, + }); + }, +}); +``` + +### Configuring the service + +There's additional configuration that you can optionally pass to setup the `identity` core service. + +- `issuer` - Set an optional issuer for validation of the JWT token +- `algorithms` - `alg` header for validation of the JWT token, defaults to `ES256`. More info on supported algorithms can be found in the [`jose` library documentation](https://github.com/panva/jose) + +You can configure these additional options by adding an override for the core service when calling `createBackend` like follows: + +```ts +import { identityFactory } from '@backstage/backend-app-api'; + +const backend = createBackend({ + services: [ + identityFactory({ + issuer: 'backstage', + algorithms: ['ES256', 'RS256'], + }), + ], +}); +``` + +## Lifecycle + +This service allows your plugins to register hooks for cleaning up resources as the service is shutting down (e.g. when a pod is being torn down, or when pressing `Ctrl+C` during local development). Other core services also leverage this same mechanism internally to stop themselves cleanly. + +### Using the service + +The following example shows how to get the lifecycle service in your `example` backend plugin to clean up a long running interval when the service is shutting down. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + lifecycle: coreServices.lifecycle, + logger: coreServices.logger, + }, + async init({ lifecycle, logger }) { + // some example work that we want to stop when shutting down + const interval = setInterval(async () => { + await fetch('http://google.com/keepalive').then(r => r.json()); + // do some other stuff. + }, 1000); + + lifecycle.addShutdownHook({ + fn: () => clearInterval(interval), + logger, + }); + }, + }); + }, +}); +``` + +## Root Lifecycle + +This service is the same as the lifecycle service, but should only be used by the root services. This is also where the implementation for the actual lifecycle hooks are collected and executed, so if you want to override the implementation of how those are processed, you should override this service. + +### Configure the service + +The following example shows how to override the default implementation of the lifecycle service with something that listens on different process events to the original. + +```ts +class MyCustomLifecycleService implements RootLifecycleService { + constructor(private readonly logger: LoggerService) { + ['SIGKILL', 'SIGTERM'].map(signal => + process.on(signal, () => this.shutdown()), + ); + } + + #isCalled = false; + #shutdownTasks: Array = []; + + addShutdownHook(options: LifecycleServiceShutdownHook): void { + this.#shutdownTasks.push(options); + } + + async shutdown(): Promise { + if (this.#isCalled) { + return; + } + this.#isCalled = true; + + this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`); + await Promise.all( + this.#shutdownTasks.map(async hook => { + const { logger = this.logger } = hook; + try { + await hook.fn(); + logger.info(`Shutdown hook succeeded`); + } catch (error) { + logger.error(`Shutdown hook failed, ${error}`); + } + }), + ); + } +} + +const backend = createBackend({ + services: [ + createServiceFactory({ + service: coreServices.rootLifecycle, + deps: { + logger: coreServices.rootLogger, + }, + async factory({ logger }) { + return new MyCustomLifecycleService(logger); + }, + }), + ], +}); +``` + +## Permissions + +This service allows your plugins to ask [the permissions framework](https://backstage.io/docs/permissions/overview) for authorization of user actions. + +### Using the service + +The following example shows how to get the permissions service in your `example` backend to check to see if the user is allowed to perform a certain action with a custom permission rule. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { Router } from 'express'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + permissions: coreServices.permissions, + http: coreServices.httpRouter, + }, + async init({ permissions, http }) { + const router = Router(); + router.get('/test-me', (request, response) => { + // use the identity service to pull out the token from request headers + const { token } = await identity.getIdentity({ + request, + }); + + // ask the permissions framework what the decision is for the permission + const permissionResponse = await permissions.authorize( + [ + { + permission: myCustomPermission, + }, + ], + { token }, + ); + }); + + http.use(router); + }, + }); + }, +}); +``` + +## Scheduler + +When writing plugins, you sometimes want to have things running on a schedule, or something similar to cron jobs that are distributed through instances that your backend plugin is running on. We supply a task scheduler for this purpose that is scoped per plugin so that you can create these tasks and orchestrate their execution. + +### Using the service + +The following example shows how to get the scheduler service in your `example` backend to issue a scheduled task that runs across your instances at a given interval. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { fetch } from 'node-fetch'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + scheduler: coreServices.scheduler, + }, + async init({ scheduler }) { + await scheduler.scheduleTask({ + frequency: { minutes: 10 }, + timeout: { seconds: 30 }, + id: 'ping-google', + fn: async () => { + await fetch('http://google.com/ping'); + }, + }); + }, + }); + }, +}); +``` + +## URL Readers + +Plugins will require communication with certain integrations that users have configured. Popular integrations are things like Version Control Systems (VSC), such as GitHub, BitBucket GitLab etc. These integrations are configured in the `integrations` section of the `app-config.yaml` file. + +These URL readers are basically wrappers with authentication for files and folders that could be stored in these VCS repositories. + +### Using the service + +The following example shows how to get the URL Reader service in your `example` backend plugin to read a file and a directory from a GitHub repository. + +```ts +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import os from 'os'; + +createBackendPlugin({ + id: 'example', + register(env) { + env.registerInit({ + deps: { + urlReader: coreServices.urlReader, + }, + async init({ urlReader }) { + const buffer = await urlReader + .read('https://github.com/backstage/backstage/blob/master/README.md') + .then(r => r.buffer()); + + const tmpDir = os.tmpdir(); + const directory = await urlReader + .readTree( + 'https://github.com/backstage/backstage/tree/master/packages/backend', + ) + .then(tree => tree.dir({ targetDir: tmpDir })); + }, + }); + }, +}); +``` diff --git a/docs/backend-system/index.md b/docs/backend-system/index.md new file mode 100644 index 0000000000..ca13bc188c --- /dev/null +++ b/docs/backend-system/index.md @@ -0,0 +1,15 @@ +--- +id: index +title: The Backend System +sidebar_label: Overview +# prettier-ignore +description: The backend system +--- + +> **DISCLAIMER: The new backend system is under active development and is not considered stable** + +## Status + +The new backend system is under active development, and only a small number of plugins have been migrated so far. It is possible to try it out, but it is not recommended to use this new system in production yet. + +You can find an example backend setup in [the backend-next package](https://github.com/backstage/backstage/tree/master/packages/backend-next). diff --git a/docs/plugins/feature-flags.md b/docs/plugins/feature-flags.md index d9841598bb..a572143d6e 100644 --- a/docs/plugins/feature-flags.md +++ b/docs/plugins/feature-flags.md @@ -56,7 +56,7 @@ The users selection is saved in the users browsers local storage. Once toggled i The easiest way to control content based on the state of a feature flag is to use the [FeatureFlagged](https://backstage.io/docs/reference/core-app-api.featureflagged) component. ```ts -import { FeatureFlagged } from '@backstage/core-app-api' +import { FeatureFlagged } from '@backstage/core-app-api'; ... diff --git a/docs/plugins/new-backend-system.md b/docs/plugins/new-backend-system.md index e5c318d5c2..bfc6f79661 100644 --- a/docs/plugins/new-backend-system.md +++ b/docs/plugins/new-backend-system.md @@ -8,13 +8,13 @@ description: Details of the upcoming backend system ## Status -The new backend system is under active development, and only a small number of plugins and services have been migrated so far. It is possible to try it out, but it is not recommended to use this new system in production yet. +The new backend system is under active development, and only a small number of plugins have been migrated so far. It is possible to try it out, but it is not recommended to use this new system in production yet. -You can find an example backend setup at https://github.com/backstage/backstage/tree/master/packages/backend-next. +You can find an example backend setup in [the backend-next package](https://github.com/backstage/backstage/tree/master/packages/backend-next). ## Overview -The new Backstage backend system is being built to help make it simpler to install backend plugins and keep projects up to date. It also changes the foundation to one that makes it a lot easier to evolve plugins and the system itself. You can read more about the reasoning in the [original RFC](https://github.com/backstage/backstage/issues/11611). +The new Backstage backend system is being built to help make it simpler to install backend plugins and to keep projects up to date. It also changes the foundation to one that makes it a lot easier to evolve plugins and the system itself with minimal disruption or cause for breaking changes. You can read more about the reasoning in the [original RFC](https://github.com/backstage/backstage/issues/11611). One of the goals of the new system was to reduce the code needed for setting up a Backstage backend and installing plugins. This is an example of how you create, add features, and start up your backend in the new system: @@ -32,21 +32,21 @@ backend.add(catalogPlugin()); await backend.start(); ``` -One notable change that helped achieve this much slimmer backend setup is the introduction of dependency injection, with a system that is very similar to the one in the Backstage frontend. +One notable change that helped achieve this much slimmer backend setup is the introduction of a system for dependency injection, which is very similar to the one in the Backstage frontend. ## Building Blocks -This section introduces the high-level building blocks upon which this new system is built. These are all concepts that exist in our current system in one way or another, but the have all been lifted up to be first class concerns in the new system. +This section introduces the high-level building blocks upon which this new system is built. These are all concepts that exist in our current system in one way or another, but they have all been lifted up to be first class concerns in the new system. ### Backend -This is the backend instance itself, which you can think of as the unit of deployment. It does not have any functionality in itself, but is simply responsible for wiring things together. +This is the backend instance itself, which you can think of as the unit of deployment. It does not have any functionality in and of itself, but is simply responsible for wiring things together. It is up to you to decide how many different backends you want to deploy. You can have all features in a single one, or split things out into multiple smaller deployments. All depending on your need to scale and isolate individual features. ### Plugins -Plugins provide the actual features, just like in our existing system. They operate completely independently of each other. If plugins what to communicate with each other, they must do so over the wire. There can be no direct communication between plugins through code. Because of this constraints, each plugins can be considered to be its own microservice. +Plugins provide the actual features, just like in our existing system. They operate completely independently of each other. If plugins want to communicate with each other, they must do so over the wire. There can be no direct communication between plugins through code. Because of this constraint, each plugin can be considered to be its own microservice. ### Services @@ -77,6 +77,7 @@ Plugins are created using the `createBackendPlugin` function. All plugins must h ```ts import { configServiceRef, + coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; @@ -89,7 +90,7 @@ export const examplePlugin = createBackendPlugin({ register(env) { env.registerInit({ deps: { - logger: loggerServiceRef, + logger: coreServices.logger, }, // logger is provided by the backend based on the dependency on loggerServiceRef above. async init({ logger }) { @@ -113,7 +114,7 @@ export const examplePlugin = createBackendPlugin({ id: 'example', register(env, options?: { silent?: boolean }) { env.registerInit({ - deps: { logger: loggerServiceRef }, + deps: { logger: coreServices.logger }, async init({ logger }) { if (!options?.silent) { logger.info('Hello from example plugin'); @@ -188,7 +189,7 @@ Extension points are registered by a plugin and extended by modules. ## Backend Services -The default backend provides several _services_ out of the box which includes access to configuration, logging, databases and more. +The default backend provides several [core services](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/coreServices.ts) out of the box which includes access to configuration, logging, databases and more. Service dependencies are declared using their `ServiceRef`s in the `deps` section of the plugin or module, and the implementations are then forwarded to the `init` method of the plugin or module. ### Service References @@ -204,8 +205,7 @@ ServiceRefs contain a scope which is used to determine if the serviceFactory cre ```ts import { createServiceFactory, - pluginMetadataServiceRef, - loggerServiceRef, + coreServices, } from '@backstage/backend-plugin-api'; import { ExampleImpl } from './ExampleImpl'; @@ -223,11 +223,11 @@ export const exampleServiceRef = createServiceRef({ createServiceFactory({ service, deps: { - logger: loggerServiceRef, - plugin: pluginMetadataServiceRef, + logger: coreServices.logger, + plugin: coreServices.pluginMetadata, }, // Logger is available directly in the factory as it's a root scoped service and will be created once per backend instance. - async factory({ logger }) { + async factory({ logger, plugin }) { // plugin is available as it's a plugin scoped service and will be created once per plugin. return async ({ plugin }) => { // This block will be executed once for every plugin that depends on this service @@ -277,20 +277,20 @@ const backend = createBackend({ ## Testing Utilities for testing backend plugins and modules are available in `@backstage/backend-test-utils`. +`startTestBackend` returns the HTTP which can be used together with `supertest` to test the plugin. ```ts import { startTestBackend } from '@backstage/backend-test-utils'; +import request from 'supertest'; -describe('Example', () => { - it('should do something', async () => { - await startTestBackend({ - // mock services can be provided to the backend - services: [someServiceFactory], - // plugins and modules for testing - features: [testModule()], +describe('My plugin tests', () => { + it('should return 200', async () => { + const { server } = await startTestBackend({ + features: [myPlugin()], }); - // assertions + const response = await request(server).get('/api/example/hello'); + expect(response.status).toBe(200); }); }); ``` diff --git a/microsite/data/plugins/aws-codesuite.yaml b/microsite/data/plugins/aws-codesuite.yaml new file mode 100644 index 0000000000..d1fd039b9b --- /dev/null +++ b/microsite/data/plugins/aws-codesuite.yaml @@ -0,0 +1,10 @@ +--- +title: AWS CodeSuite +author: Amazon Web Services +authorUrl: https://aws.amazon.com/ +category: CI/CD +description: View resources related to AWS CodePipeline, AWS CodeBuild and AWS CodeDeploy for your components in Backstage. +documentation: https://github.com/awslabs/aws-codesuite-plugins-for-backstage#readme +iconUrl: https://github.com/awslabs/aws-codesuite-plugins-for-backstage/blob/main/docs/images/codepipeline-logo.png?raw=true +npmPackageName: '@aws/aws-codesuite-plugin-for-backstage' +addedDate: '2023-01-17' diff --git a/microsite/data/plugins/cloudsmith.yaml b/microsite/data/plugins/cloudsmith.yaml index 5fc417eec1..650f0b253c 100644 --- a/microsite/data/plugins/cloudsmith.yaml +++ b/microsite/data/plugins/cloudsmith.yaml @@ -3,11 +3,14 @@ title: Cloudsmith author: Roadie authorUrl: https://roadie.io category: CI/CD -description: Show Cloudsmith Repository stats on your backstage homepage. +description: Show Cloudsmith Repository stats, Quota, Audit logs and Security scans for specific repositories. documentation: https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/frontend/backstage-plugin-cloudsmith iconUrl: https://cloudsmith.com/img/cloudsmith-mini-dark.svg npmPackageName: '@roadiehq/backstage-plugin-cloudsmith' tags: - dashboards - monitoring + - audit logs + - security scanning + - usage addedDate: '2022-11-18' diff --git a/microsite/pages/en/live.js b/microsite/pages/en/live.js index e2f3267b62..166e3bf13a 100644 --- a/microsite/pages/en/live.js +++ b/microsite/pages/en/live.js @@ -34,7 +34,7 @@ const Background = props => { diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 37b9f8b8ec..02e0c82fd4 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -239,8 +239,7 @@ "plugins/proxying", "plugins/backend-plugin", "plugins/call-existing-api", - "plugins/url-reader", - "plugins/new-backend-system" + "plugins/url-reader" ] }, { @@ -363,6 +362,47 @@ "architecture-decisions/adrs-adr012", "architecture-decisions/adrs-adr013" ], - "FAQ": ["FAQ"] + "FAQ": ["FAQ"], + "Experimental Backend System": [ + { + "_hidden_": [ + "backend-system/index", + { + "type": "subcategory", + "label": "Architecture", + "ids": [ + "backend-system/architecture/index", + "backend-system/architecture/services", + "backend-system/architecture/plugins", + "backend-system/architecture/extension-points", + "backend-system/architecture/modules", + "backend-system/architecture/naming-patterns" + ] + }, + { + "type": "subcategory", + "label": "Building Backends", + "ids": [ + "backend-system/building-backends/index", + "backend-system/building-backends/migrating" + ] + }, + { + "type": "subcategory", + "label": "Building Plugins & Modules", + "ids": [ + "backend-system/building-plugins-and-modules/index", + "backend-system/building-plugins-and-modules/testing", + "backend-system/building-plugins-and-modules/migrating" + ] + }, + { + "type": "subcategory", + "label": "Core Services", + "ids": ["backend-system/core-services/index"] + } + ] + } + ] } } diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 59f9571a70..a2a63e2473 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -1244,12 +1244,24 @@ h3.collapsible span.arrow { transform: translateY(-50%) translateX(500px); } +@media only screen and (max-height: 675px) { + .Sidebar__Container { + top: 65%; + } +} + +@media only screen and (max-width: 650px) { + .Sidebar__Container { + transform: translateY(-50%) translateX(300px); + } +} + .Sidebar__Container--open { transform: translateY(-50%); } .Sidebar__Button { - transform: rotate(-90deg) translateY(48px); + transform: rotate(-90deg) translateY(49px); padding: 12px 16px; border-radius: 8px 8px 0 0; background-color: rgb(92, 214, 200); @@ -1263,18 +1275,34 @@ h3.collapsible span.arrow { cursor: pointer; } +@media only screen and (max-width: 650px) { + .Sidebar__Button { + width: 175px; + font-size: 15px; + } +} + .Sidebar__Button:hover { - transform: rotate(-90deg) translateY(48px) scale(1.01); + transform: rotate(-90deg) translateY(49px) scale(1.01); } #Sidebar__HubSpotContainer { width: 500px; background-color: white; border-radius: 8px 0 0 8px; - padding: 16px; - padding-bottom: 0px; - z-index: 10001; - min-height: 260px; + height: 500px; +} + +@media only screen and (max-width: 650px) { + #Sidebar__HubSpotContainer { + width: 300px; + } +} + +#Sidebar__HubSpotContainer form { + overflow-y: auto; + height: 100%; + padding: 25px 25px 0px 25px; } #Sidebar__HubSpotContainer .hs-button { @@ -1284,6 +1312,7 @@ h3.collapsible span.arrow { color: rgb(0, 0, 0); border: 0; } + #Sidebar__HubSpotContainer.submitted-message { min-height: 260px; display: flex; diff --git a/mkdocs.yml b/mkdocs.yml index f3194f0d61..38369f4748 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -138,7 +138,6 @@ nav: - Backend plugin: 'plugins/backend-plugin.md' - Call existing API: 'plugins/call-existing-api.md' - URL Reader: 'plugins/url-reader.md' - - New Backend System: 'plugins/new-backend-system.md' - Testing: - Testing with Jest: 'plugins/testing.md' - Publishing: diff --git a/packages/app/package.json b/packages/app/package.json index 21d1108188..2684d5ccb6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -84,7 +84,7 @@ }, "devDependencies": { "@backstage/test-utils": "workspace:^", - "@testing-library/cypress": "^8.0.2", + "@testing-library/cypress": "^9.0.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c5410c4f80..8984a69259 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -244,6 +244,9 @@ const routes = ( + + + } /> ({ id: 'foo', scope: 'root' }); @@ -112,7 +112,7 @@ describe('createBackend', () => { }; }, }), - mockConfigFactory({ + mockServices.config.factory({ data: { root: 'root-env' }, }), createServiceFactory({ diff --git a/packages/backend-plugin-api/src/wiring/factories.ts b/packages/backend-plugin-api/src/wiring/factories.ts index 31a64778f2..ad2e8edd13 100644 --- a/packages/backend-plugin-api/src/wiring/factories.ts +++ b/packages/backend-plugin-api/src/wiring/factories.ts @@ -74,10 +74,10 @@ export interface BackendModuleConfig { * * @remarks * - * The `moduleId` should be equal to the module-specific prefix of the exported name, such - * that the full name is `moduleId + PluginId + "Module"`. For example, a GitHub entity + * The `moduleId` should be equal to the module-specific suffix of the exported name, such + * that the full name is `pluginId + "Module" + ModuleId`. For example, a GitHub entity * provider module for the `catalog` plugin might have the module ID `'githubEntityProvider'`, - * and the full exported name would be `githubEntityProviderCatalogModule`. + * and the full exported name would be `catalogModuleGithubEntityProvider`. * * The `pluginId` should exactly match the `id` of the plugin that the module extends. */ diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 8ff29f8545..232f7e8136 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -5,25 +5,150 @@ ```ts import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CacheService } from '@backstage/backend-plugin-api'; +import { ConfigReader } from '@backstage/config'; import { ConfigService } from '@backstage/backend-plugin-api'; +import { DatabaseService } from '@backstage/backend-plugin-api'; import { ExtendedHttpServer } from '@backstage/backend-app-api'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { HttpRouterFactoryOptions } from '@backstage/backend-app-api'; +import { HttpRouterService } from '@backstage/backend-plugin-api'; +import { IdentityService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { Knex } from 'knex'; +import { LifecycleService } from '@backstage/backend-plugin-api'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { PermissionsService } from '@backstage/backend-plugin-api'; +import { RootLifecycleService } from '@backstage/backend-plugin-api'; +import { RootLoggerService } from '@backstage/backend-plugin-api'; +import { SchedulerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { TokenManagerService } from '@backstage/backend-plugin-api'; +import { UrlReaderService } from '@backstage/backend-plugin-api'; // @public (undocumented) export function isDockerDisabledForTests(): boolean; // @alpha (undocumented) -export const mockConfigFactory: ( - options?: - | { - data?: JsonObject | undefined; - } - | undefined, -) => ServiceFactory; +export namespace mockServices { + // (undocumented) + export namespace cache { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export function config(options?: config.Options): ConfigReader; + // (undocumented) + export namespace config { + // (undocumented) + export type Options = { + data?: JsonObject; + }; + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: (options?: Options | undefined) => ServiceFactory; + } + // (undocumented) + export namespace database { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace httpRouter { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: ( + options?: HttpRouterFactoryOptions | undefined, + ) => ServiceFactory; + } + // (undocumented) + export function identity(): IdentityService; + // (undocumented) + export namespace identity { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace lifecycle { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace logger { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace permissions { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace rootLifecycle { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export function rootLogger(options?: rootLogger.Options): LoggerService; + // (undocumented) + export namespace rootLogger { + // (undocumented) + export type Options = { + levels: + | boolean + | { + error: boolean; + warn: boolean; + info: boolean; + debug: boolean; + }; + }; + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: (options?: Options | undefined) => ServiceFactory; + } + // (undocumented) + export namespace scheduler { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export function tokenManager(): TokenManagerService; + // (undocumented) + export namespace tokenManager { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } + // (undocumented) + export namespace urlReader { + const // (undocumented) + ref: ServiceRef; + const // (undocumented) + factory: () => ServiceFactory; + } +} // @public export function setupRequestMockHandlers(worker: { diff --git a/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts b/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts deleted file mode 100644 index 2cfc15220e..0000000000 --- a/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { TokenManager } from '@backstage/backend-common'; -import { - coreServices, - createServiceFactory, -} from '@backstage/backend-plugin-api'; - -class TokenManagerMock implements TokenManager { - async getToken(): Promise<{ token: string }> { - return { token: 'mock-token' }; - } - async authenticate(token: string): Promise { - if (token !== 'mock-token') { - throw new Error('Invalid token'); - } - } -} - -/** @alpha */ -export const mockTokenManagerFactory = createServiceFactory({ - service: coreServices.tokenManager, - deps: {}, - async factory() { - return new TokenManagerMock(); - }, -}); diff --git a/packages/backend-test-utils/src/next/index.ts b/packages/backend-test-utils/src/next/index.ts index 9f9edfd837..572fb3a9e7 100644 --- a/packages/backend-test-utils/src/next/index.ts +++ b/packages/backend-test-utils/src/next/index.ts @@ -15,4 +15,4 @@ */ export * from './wiring'; -export * from './implementations'; +export * from './services'; diff --git a/packages/backend-test-utils/src/next/implementations/mockIdentityService.ts b/packages/backend-test-utils/src/next/services/MockIdentityService.ts similarity index 75% rename from packages/backend-test-utils/src/next/implementations/mockIdentityService.ts rename to packages/backend-test-utils/src/next/services/MockIdentityService.ts index a838c8c879..ff424f79c2 100644 --- a/packages/backend-test-utils/src/next/implementations/mockIdentityService.ts +++ b/packages/backend-test-utils/src/next/services/MockIdentityService.ts @@ -13,17 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - coreServices, - createServiceFactory, - IdentityService, -} from '@backstage/backend-plugin-api'; +import { IdentityService } from '@backstage/backend-plugin-api'; import { IdentityApiGetIdentityRequest, BackstageIdentityResponse, } from '@backstage/plugin-auth-node'; -class MockIdentityServiceImpl implements IdentityService { +export class MockIdentityService implements IdentityService { getIdentity( _options: IdentityApiGetIdentityRequest, ): Promise { @@ -37,11 +33,3 @@ class MockIdentityServiceImpl implements IdentityService { }); } } - -export const mockIdentityFactory = createServiceFactory({ - service: coreServices.identity, - deps: {}, - async factory() { - return new MockIdentityServiceImpl(); - }, -}); diff --git a/packages/backend-test-utils/src/next/implementations/mockRootLoggerService.ts b/packages/backend-test-utils/src/next/services/MockRootLoggerService.ts similarity index 74% rename from packages/backend-test-utils/src/next/implementations/mockRootLoggerService.ts rename to packages/backend-test-utils/src/next/services/MockRootLoggerService.ts index bb3af6a9fb..780d330bfb 100644 --- a/packages/backend-test-utils/src/next/implementations/mockRootLoggerService.ts +++ b/packages/backend-test-utils/src/next/services/MockRootLoggerService.ts @@ -15,21 +15,14 @@ */ import { - coreServices, - createServiceFactory, LoggerService, LogMeta, RootLoggerService, } from '@backstage/backend-plugin-api'; +import type { mockServices } from './mockServices'; -interface MockLoggerOptions { - levels: - | boolean - | { error: boolean; warn: boolean; info: boolean; debug: boolean }; -} - -class MockLogger implements RootLoggerService { - #levels: Exclude; +export class MockRootLoggerService implements RootLoggerService { + #levels: Exclude; #meta: LogMeta; error(message: string, meta?: LogMeta | Error | undefined): void { @@ -49,10 +42,13 @@ class MockLogger implements RootLoggerService { } child(meta: LogMeta): LoggerService { - return new MockLogger(this.#levels, { ...this.#meta, ...meta }); + return new MockRootLoggerService(this.#levels, { ...this.#meta, ...meta }); } - constructor(levels: MockLoggerOptions['levels'], meta: LogMeta) { + constructor( + levels: mockServices.rootLogger.Options['levels'], + meta: LogMeta, + ) { if (typeof levels === 'boolean') { this.#levels = { error: levels, @@ -79,12 +75,3 @@ class MockLogger implements RootLoggerService { } } } - -/** @alpha */ -export const mockRootLoggerService = createServiceFactory({ - service: coreServices.rootLogger, - deps: {}, - async factory(_deps) { - return new MockLogger(false, {}); - }, -}); diff --git a/packages/backend-test-utils/src/next/implementations/index.ts b/packages/backend-test-utils/src/next/services/index.ts similarity index 91% rename from packages/backend-test-utils/src/next/implementations/index.ts rename to packages/backend-test-utils/src/next/services/index.ts index 55f417f8df..f562150efa 100644 --- a/packages/backend-test-utils/src/next/implementations/index.ts +++ b/packages/backend-test-utils/src/next/services/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { mockConfigFactory } from './mockConfigService'; +export { mockServices } from './mockServices'; diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts new file mode 100644 index 0000000000..f86d8bac55 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -0,0 +1,147 @@ +/* + * 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 { + coreServices, + createServiceFactory, + IdentityService, + LoggerService, + ServiceFactory, + ServiceRef, + TokenManagerService, +} from '@backstage/backend-plugin-api'; +import { + cacheFactory, + databaseFactory, + httpRouterFactory, + lifecycleFactory, + loggerFactory, + permissionsFactory, + rootLifecycleFactory, + schedulerFactory, + urlReaderFactory, +} from '@backstage/backend-app-api'; +import { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { MockIdentityService } from './MockIdentityService'; +import { MockRootLoggerService } from './MockRootLoggerService'; + +function simpleFactory( + ref: ServiceRef, + factory: (...options: TOptions) => TService, +): (...options: TOptions) => ServiceFactory { + return createServiceFactory((options: unknown) => ({ + service: ref as ServiceRef, + deps: {}, + async factory() { + return (factory as any)(options); + }, + })); +} + +/** + * @alpha + */ +export namespace mockServices { + export function config(options?: config.Options) { + return new ConfigReader(options?.data, 'mock-config'); + } + export namespace config { + export type Options = { data?: JsonObject }; + + export const ref = coreServices.config; + export const factory = simpleFactory(ref, config); + } + + export function rootLogger(options?: rootLogger.Options): LoggerService { + return new MockRootLoggerService(options?.levels ?? false, {}); + } + export namespace rootLogger { + export type Options = { + levels: + | boolean + | { error: boolean; warn: boolean; info: boolean; debug: boolean }; + }; + + export const ref = coreServices.rootLogger; + export const factory = simpleFactory(ref, rootLogger); + } + + export function tokenManager(): TokenManagerService { + return { + async getToken(): Promise<{ token: string }> { + return { token: 'mock-token' }; + }, + async authenticate(token: string): Promise { + if (token !== 'mock-token') { + throw new Error('Invalid token'); + } + }, + }; + } + export namespace tokenManager { + export const ref = coreServices.tokenManager; + export const factory = simpleFactory(ref, tokenManager); + } + + export function identity(): IdentityService { + return new MockIdentityService(); + } + export namespace identity { + export const ref = coreServices.identity; + export const factory = simpleFactory(ref, identity); + } + + // TODO(Rugvip): Not all core services have implementations available here yet. + // some may need a bit more refactoring for it to be simpler to + // re-implement functioning mock versions here. + export namespace cache { + export const ref = coreServices.cache; + export const factory = cacheFactory; + } + export namespace database { + export const ref = coreServices.database; + export const factory = databaseFactory; + } + export namespace httpRouter { + export const ref = coreServices.httpRouter; + export const factory = httpRouterFactory; + } + export namespace lifecycle { + export const ref = coreServices.lifecycle; + export const factory = lifecycleFactory; + } + export namespace logger { + export const ref = coreServices.logger; + export const factory = loggerFactory; + } + export namespace permissions { + export const ref = coreServices.permissions; + export const factory = permissionsFactory; + } + export namespace rootLifecycle { + export const ref = coreServices.rootLifecycle; + export const factory = rootLifecycleFactory; + } + export namespace scheduler { + export const ref = coreServices.scheduler; + export const factory = schedulerFactory; + } + export namespace urlReader { + export const ref = coreServices.urlReader; + export const factory = urlReaderFactory; + } +} diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 78c2f78e5e..db1016fcb6 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -17,15 +17,6 @@ import { Backend, createSpecializedBackend, - lifecycleFactory, - rootLifecycleFactory, - loggerFactory, - cacheFactory, - permissionsFactory, - schedulerFactory, - urlReaderFactory, - databaseFactory, - httpRouterFactory, MiddlewareFactory, createHttpServer, ExtendedHttpServer, @@ -40,13 +31,9 @@ import { ExtensionPoint, coreServices, } from '@backstage/backend-plugin-api'; - -import { mockConfigFactory } from '../implementations/mockConfigService'; -import { mockRootLoggerService } from '../implementations/mockRootLoggerService'; -import { mockTokenManagerFactory } from '../implementations/mockTokenManagerService'; +import { mockServices } from '../services'; import { ConfigReader } from '@backstage/config'; import express from 'express'; -import { mockIdentityFactory } from '../implementations/mockIdentityService'; /** @alpha */ export interface TestBackendOptions< @@ -84,19 +71,19 @@ export interface TestBackend extends Backend { } const defaultServiceFactories = [ - cacheFactory(), - databaseFactory(), - httpRouterFactory(), - lifecycleFactory(), - loggerFactory(), - mockConfigFactory(), - mockRootLoggerService(), - mockIdentityFactory(), - mockTokenManagerFactory(), - permissionsFactory(), - rootLifecycleFactory(), - schedulerFactory(), - urlReaderFactory(), + mockServices.cache.factory(), + mockServices.config.factory(), + mockServices.database.factory(), + mockServices.httpRouter.factory(), + mockServices.identity.factory(), + mockServices.lifecycle.factory(), + mockServices.logger.factory(), + mockServices.permissions.factory(), + mockServices.rootLifecycle.factory(), + mockServices.rootLogger.factory(), + mockServices.scheduler.factory(), + mockServices.tokenManager.factory(), + mockServices.urlReader.factory(), ]; const backendInstancesToCleanUp = new Array(); diff --git a/packages/techdocs-cli/cli-report.md b/packages/techdocs-cli/cli-report.md index 9230fe9f1c..861dad4605 100644 --- a/packages/techdocs-cli/cli-report.md +++ b/packages/techdocs-cli/cli-report.md @@ -52,7 +52,6 @@ Options: --awsRoleArn --awsEndpoint --awsS3ForcePathStyle - --awsBucketRootPath --osCredentialId --osSecret --osAuthUrl @@ -79,11 +78,12 @@ Options: --awsEndpoint --awsS3sse --awsS3ForcePathStyle + --awsBucketRootPath --osCredentialId --osSecret --osAuthUrl --osSwiftUrl - --gcsBucketRootPath + --gcsBucketRootPath --directory -h, --help ``` diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts index d4303ad306..caa7d8efbd 100644 --- a/packages/techdocs-cli/src/commands/index.ts +++ b/packages/techdocs-cli/src/commands/index.ts @@ -101,10 +101,6 @@ export function registerCommands(program: Command) { '--awsS3ForcePathStyle', 'Optional AWS S3 option to force path style.', ) - .option( - '--awsBucketRootPath', - 'Optional sub-directory to store files in Amazon S3', - ) .option( '--osCredentialId ', '(Required for OpenStack) specify when --publisher-type openStackSwift', @@ -177,6 +173,10 @@ export function registerCommands(program: Command) { '--awsS3ForcePathStyle', 'Optional AWS S3 option to force path style.', ) + .option( + '--awsBucketRootPath ', + 'Optional sub-directory to store files in Amazon S3', + ) .option( '--osCredentialId ', '(Required for OpenStack) specify when --publisher-type openStackSwift', @@ -194,7 +194,7 @@ export function registerCommands(program: Command) { '(Required for OpenStack) specify when --publisher-type openStackSwift', ) .option( - '--gcsBucketRootPath', + '--gcsBucketRootPath ', 'Optional sub-directory to store files in Google cloud storage', ) .option( diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index bea820cb61..27e19b218d 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -164,6 +164,7 @@ This also may require you to adjust `Content Security Policy` header settings of ```yaml script-src: - "'self'" + - "'unsafe-eval'" # this is required for scaffolder usage, and ajv validation. - "'sha256-GeDavzSZ8O71Jggf/pQkKbt52dfZkrdNMQ3e+Ox+AkI='" # oauth2-redirect.html ``` diff --git a/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts index ffb84d1c34..fded78aed4 100644 --- a/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts +++ b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts @@ -27,6 +27,7 @@ export class AwsCredentials { accessKeyId?: string; secretAccessKey?: string; roleArn?: string; + externalId?: string; }, roleSessionName: string, ): Credentials | CredentialsOptions | undefined { @@ -52,6 +53,7 @@ export class AwsCredentials { params: { RoleArn: roleArn, RoleSessionName: roleSessionName, + ExternalId: config.externalId, }, }); } diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts index d44281600c..1b18ab0ed9 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts @@ -19,10 +19,7 @@ import { PluginTaskScheduler, TaskScheduleDefinition, } from '@backstage/backend-tasks'; -import { - startTestBackend, - mockConfigFactory, -} from '@backstage/backend-test-utils'; +import { startTestBackend, mockServices } from '@backstage/backend-test-utils'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; import { eventsExtensionPoint } from '@backstage/plugin-events-node'; import { Duration } from 'luxon'; @@ -59,7 +56,7 @@ describe('bitbucketCloudEntityProviderCatalogModule', () => { [eventsExtensionPoint, eventsExtensionPointImpl], ], services: [ - mockConfigFactory({ + mockServices.config.factory({ data: { catalog: { providers: { diff --git a/plugins/catalog-backend-module-incremental-ingestion/api-report.md b/plugins/catalog-backend-module-incremental-ingestion/api-report.md index 387e505c33..4bf6bb9456 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/api-report.md +++ b/plugins/catalog-backend-module-incremental-ingestion/api-report.md @@ -10,6 +10,7 @@ import { CatalogBuilder } from '@backstage/plugin-catalog-backend'; import type { Config } from '@backstage/config'; import type { DeferredEntity } from '@backstage/plugin-catalog-backend'; import type { DurationObjectUnits } from 'luxon'; +import { EventParams } from '@backstage/plugin-events-node'; import type { Logger } from 'winston'; import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; import type { PluginDatabaseManager } from '@backstage/backend-common'; @@ -50,6 +51,17 @@ export class IncrementalCatalogBuilder { // @public export interface IncrementalEntityProvider { around(burst: (context: TContext) => Promise): Promise; + eventHandler?: { + onEvent: (params: EventParams) => + | undefined + | { + added: DeferredEntity[]; + removed: { + entityRef: string; + }[]; + }; + supportsEventTopics: () => string[]; + }; getProviderName(): string; next( context: TContext, diff --git a/plugins/catalog-backend-module-incremental-ingestion/package.json b/plugins/catalog-backend-module-incremental-ingestion/package.json index bb2f3d1678..c0eb027ac2 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/package.json +++ b/plugins/catalog-backend-module-incremental-ingestion/package.json @@ -42,6 +42,7 @@ "@backstage/errors": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts index e837f2b259..cf47942754 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/database/IncrementalIngestionDatabaseManager.ts @@ -269,6 +269,17 @@ export class IncrementalIngestionDatabaseManager { }); } + /** + * This method is used to remove entity records from the ingestion_mark_entities + * table by their entity reference. + */ + async deleteEntityRecordsByRef(entities: { entityRef: string }[]) { + const refs = entities.map(e => e.entityRef); + await this.client.transaction(async tx => { + await tx('ingestion_mark_entities').delete().whereIn('ref', refs); + }); + } + /** * Creates a new ingestion record. * @param provider - string @@ -535,6 +546,21 @@ export class IncrementalIngestionDatabaseManager { }); } + /** + * Returns the first record from `ingestion_marks` for the supplied ingestionId. + * @param ingestionId - string + * @returns MarkRecord | undefined + */ + async getFirstMark(ingestionId: string) { + return await this.client.transaction(async tx => { + const mark = await tx('ingestion_marks') + .where('ingestion_id', ingestionId) + .orderBy('sequence', 'asc') + .first(); + return mark; + }); + } + async getAllMarks(ingestionId: string) { return await this.client.transaction(async tx => { const marks = await tx('ingestion_marks') diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts index e94bc9c53f..228c7a2505 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts @@ -21,8 +21,11 @@ import { performance } from 'perf_hooks'; import { Duration, DurationObjectUnits } from 'luxon'; import { v4 } from 'uuid'; import { stringifyError } from '@backstage/errors'; +import { EventParams, EventSubscriber } from '@backstage/plugin-events-node'; -export class IncrementalIngestionEngine implements IterationEngine { +export class IncrementalIngestionEngine + implements IterationEngine, EventSubscriber +{ private readonly restLength: Duration; private readonly backoff: DurationObjectUnits[]; @@ -329,4 +332,71 @@ export class IncrementalIngestionEngine implements IterationEngine { removed, }); } + + async onEvent(params: EventParams): Promise { + const { topic } = params; + if (!this.supportsEventTopics().includes(topic)) { + return; + } + + const { logger, provider, connection } = this.options; + const providerName = provider.getProviderName(); + logger.debug(`incremental-engine: ${providerName} received ${topic} event`); + + if (!provider.eventHandler) { + return; + } + + const delta = provider.eventHandler.onEvent(params); + + if (delta) { + if (delta.added.length > 0) { + const ingestionRecord = await this.manager.getCurrentIngestionRecord( + providerName, + ); + + if (!ingestionRecord) { + logger.debug( + `incremental-engine: ${providerName} skipping delta addition because incremental ingestion is restarting.`, + ); + } else { + const mark = + ingestionRecord.status === 'resting' + ? await this.manager.getLastMark(ingestionRecord.id) + : await this.manager.getFirstMark(ingestionRecord.id); + + if (!mark) { + throw new Error( + `Cannot apply delta, page records are missing! Please re-run incremental ingestion for ${providerName}.`, + ); + } + await this.manager.createMarkEntities(mark.id, delta.added); + } + } + + if (delta.removed.length > 0) { + await this.manager.deleteEntityRecordsByRef(delta.removed); + } + + await connection.applyMutation({ + type: 'delta', + ...delta, + }); + logger.debug( + `incremental-engine: ${providerName} processed delta from '${topic}' event`, + ); + } else { + logger.warn( + `incremental-engine: Rejected delta from '${topic}' event - empty or invalid`, + ); + } + } + + supportsEventTopics(): string[] { + const { provider } = this.options; + const topics = provider.eventHandler + ? provider.eventHandler.supportsEventTopics() + : []; + return topics; + } } diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.ts b/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.ts index abe4cdb951..4738893c95 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/module/WrapperProviders.ts @@ -31,7 +31,7 @@ import { Duration } from 'luxon'; import { IncrementalIngestionDatabaseManager } from '../database/IncrementalIngestionDatabaseManager'; import { applyDatabaseMigrations } from '../database/migrations'; import { IncrementalIngestionEngine } from '../engine/IncrementalIngestionEngine'; -import { createIncrementalProviderRouter } from '../router/routes'; +import { IncrementalProviderRouter } from '../router/routes'; import { IncrementalEntityProvider, IncrementalEntityProviderOptions, @@ -75,10 +75,10 @@ export class WrapperProviders { } async adminRouter(): Promise { - return createIncrementalProviderRouter( + return await new IncrementalProviderRouter( new IncrementalIngestionDatabaseManager({ client: this.options.client }), loggerToWinstonLogger(this.options.logger), - ); + ).createRouter(); } private async startProvider( diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/router/routes.ts b/plugins/catalog-backend-module-incremental-ingestion/src/router/routes.ts index 9f480e879e..fc26abdf32 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/router/routes.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/router/routes.ts @@ -21,212 +21,219 @@ import { Logger } from 'winston'; import { IncrementalIngestionDatabaseManager } from '../database/IncrementalIngestionDatabaseManager'; import { PROVIDER_BASE_PATH, PROVIDER_CLEANUP, PROVIDER_HEALTH } from './paths'; -export const createIncrementalProviderRouter = async ( - manager: IncrementalIngestionDatabaseManager, - logger: Logger, -) => { - const router = Router(); - router.use(express.json()); +export class IncrementalProviderRouter { + private manager: IncrementalIngestionDatabaseManager; + private logger: Logger; - // Get the overall health of all incremental providers - router.get(PROVIDER_HEALTH, async (_, res) => { - const records = await manager.healthcheck(); - const providers = records.map(record => record.provider_name); - const duplicates = [ - ...new Set(providers.filter((e, i, a) => a.indexOf(e) !== i)), - ]; + constructor(manager: IncrementalIngestionDatabaseManager, logger: Logger) { + this.manager = manager; + this.logger = logger; + } - if (duplicates.length > 0) { - res.json({ healthy: false, duplicateIngestions: duplicates }); - } else { - res.json({ healthy: true }); - } - }); + async createRouter() { + const router = Router(); + router.use(express.json()); - // Clean up and pause all providers - router.post(PROVIDER_CLEANUP, async (_, res) => { - const result = await manager.cleanupProviders(); - res.json(result); - }); + // Get the overall health of all incremental providers + router.get(PROVIDER_HEALTH, async (_, res) => { + const records = await this.manager.healthcheck(); + const providers = records.map(record => record.provider_name); + const duplicates = [ + ...new Set(providers.filter((e, i, a) => a.indexOf(e) !== i)), + ]; - // Get basic status of the provider - router.get(PROVIDER_BASE_PATH, async (req, res) => { - const { provider } = req.params; - const record = await manager.getCurrentIngestionRecord(provider); - if (record) { - res.json({ - success: true, - status: { - current_action: record.status, - next_action_at: new Date(record.next_action_at), - }, - last_error: record.last_error, - }); - } else { - const providers: string[] = await manager.listProviders(); - if (providers.includes(provider)) { + if (duplicates.length > 0) { + res.json({ healthy: false, duplicateIngestions: duplicates }); + } else { + res.json({ healthy: true }); + } + }); + + // Clean up and pause all providers + router.post(PROVIDER_CLEANUP, async (_, res) => { + const result = await this.manager.cleanupProviders(); + res.json(result); + }); + + // Get basic status of the provider + router.get(PROVIDER_BASE_PATH, async (req, res) => { + const { provider } = req.params; + const record = await this.manager.getCurrentIngestionRecord(provider); + if (record) { res.json({ success: true, status: { - current_action: 'rest complete, waiting to start', + current_action: record.status, + next_action_at: new Date(record.next_action_at), }, + last_error: record.last_error, }); } else { - logger.error( - `${provider} - No ingestion record found in the database!`, - ); - res.status(404).json({ - success: false, - status: {}, - last_error: `Provider '${provider}' not found`, - }); + const providers: string[] = await this.manager.listProviders(); + if (providers.includes(provider)) { + res.json({ + success: true, + status: { + current_action: 'rest complete, waiting to start', + }, + }); + } else { + this.logger.error( + `${provider} - No ingestion record found in the database!`, + ); + res.status(404).json({ + success: false, + status: {}, + last_error: `Provider '${provider}' not found`, + }); + } } - } - }); - - // Trigger the provider's next action - router.post(`${PROVIDER_BASE_PATH}/trigger`, async (req, res) => { - const { provider } = req.params; - const record = await manager.getCurrentIngestionRecord(provider); - if (record) { - await manager.triggerNextProviderAction(provider); - res.json({ - success: true, - message: `${provider}: Next action triggered.`, - }); - } else { - const providers: string[] = await manager.listProviders(); - if (providers.includes(provider)) { - logger.debug(`${provider} - Ingestion record found`); - res.json({ - success: true, - message: 'Unable to trigger next action (provider is restarting)', - }); - } else { - res.status(404).json({ - success: false, - message: `Provider '${provider}' not found`, - }); - } - } - }); - - // Start a brand-new ingestion cycle for the provider. - // (Cancel's the current run if active, or marks it complete if resting) - router.post(`${PROVIDER_BASE_PATH}/start`, async (req, res) => { - const { provider } = req.params; - - const record = await manager.getCurrentIngestionRecord(provider); - if (record) { - const ingestionId = record.id; - if (record.status === 'resting') { - await manager.setProviderComplete(ingestionId); - } else { - await manager.setProviderCanceling(ingestionId); - } - res.json({ - success: true, - message: `${provider}: Next cycle triggered.`, - }); - } else { - const providers: string[] = await manager.listProviders(); - if (providers.includes(provider)) { - logger.debug(`${provider} - Ingestion record found`); - res.json({ - success: true, - message: 'Provider is already restarting', - }); - } else { - res.status(404).json({ - success: false, - message: `Provider '${provider}' not found`, - }); - } - } - }); - - // Stop the provider and pause it for 24 hours - router.post(`${PROVIDER_BASE_PATH}/cancel`, async (req, res) => { - const { provider } = req.params; - const record = await manager.getCurrentIngestionRecord(provider); - if (record) { - const next_action_at = new Date(); - next_action_at.setTime(next_action_at.getTime() + 24 * 60 * 60 * 1000); - await manager.updateByName(provider, { - next_action: 'nothing (done)', - ingestion_completed_at: new Date(), - next_action_at, - status: 'resting', - }); - res.json({ - success: true, - message: `${provider}: Current ingestion canceled.`, - }); - } else { - const providers: string[] = await manager.listProviders(); - if (providers.includes(provider)) { - logger.debug(`${provider} - Ingestion record found`); - res.json({ - success: true, - message: 'Provider is currently restarting, please wait.', - }); - } else { - res.status(404).json({ - success: false, - message: `Provider '${provider}' not found`, - }); - } - } - }); - - // Wipe out all ingestion records for the provider and pause for 24 hours - router.delete(PROVIDER_BASE_PATH, async (req, res) => { - const { provider } = req.params; - const result = await manager.purgeAndResetProvider(provider); - res.json(result); - }); - - // Get the ingestion marks for the current cycle - router.get(`${PROVIDER_BASE_PATH}/marks`, async (req, res) => { - const { provider } = req.params; - const record = await manager.getCurrentIngestionRecord(provider); - if (record) { - const id = record.id; - const records = await manager.getAllMarks(id); - res.json({ success: true, records }); - } else { - const providers: string[] = await manager.listProviders(); - if (providers.includes(provider)) { - logger.debug(`${provider} - Ingestion record found`); - res.json({ - success: true, - message: 'No records yet (provider is restarting)', - }); - } else { - logger.error( - `${provider} - No ingestion record found in the database!`, - ); - res.status(404).json({ - success: false, - status: {}, - last_error: `Provider '${provider}' not found`, - }); - } - } - }); - - router.delete(`${PROVIDER_BASE_PATH}/marks`, async (req, res) => { - const { provider } = req.params; - const deletions = await manager.clearFinishedIngestions(provider); - - res.json({ - success: true, - message: `Expired marks for provider '${provider}' removed.`, - deletions, }); - }); - router.use(errorHandler()); + // Trigger the provider's next action + router.post(`${PROVIDER_BASE_PATH}/trigger`, async (req, res) => { + const { provider } = req.params; + const record = await this.manager.getCurrentIngestionRecord(provider); + if (record) { + await this.manager.triggerNextProviderAction(provider); + res.json({ + success: true, + message: `${provider}: Next action triggered.`, + }); + } else { + const providers: string[] = await this.manager.listProviders(); + if (providers.includes(provider)) { + this.logger.debug(`${provider} - Ingestion record found`); + res.json({ + success: true, + message: 'Unable to trigger next action (provider is restarting)', + }); + } else { + res.status(404).json({ + success: false, + message: `Provider '${provider}' not found`, + }); + } + } + }); - return router; -}; + // Start a brand-new ingestion cycle for the provider. + // (Cancel's the current run if active, or marks it complete if resting) + router.post(`${PROVIDER_BASE_PATH}/start`, async (req, res) => { + const { provider } = req.params; + + const record = await this.manager.getCurrentIngestionRecord(provider); + if (record) { + const ingestionId = record.id; + if (record.status === 'resting') { + await this.manager.setProviderComplete(ingestionId); + } else { + await this.manager.setProviderCanceling(ingestionId); + } + res.json({ + success: true, + message: `${provider}: Next cycle triggered.`, + }); + } else { + const providers: string[] = await this.manager.listProviders(); + if (providers.includes(provider)) { + this.logger.debug(`${provider} - Ingestion record found`); + res.json({ + success: true, + message: 'Provider is already restarting', + }); + } else { + res.status(404).json({ + success: false, + message: `Provider '${provider}' not found`, + }); + } + } + }); + + // Stop the provider and pause it for 24 hours + router.post(`${PROVIDER_BASE_PATH}/cancel`, async (req, res) => { + const { provider } = req.params; + const record = await this.manager.getCurrentIngestionRecord(provider); + if (record) { + const next_action_at = new Date(); + next_action_at.setTime(next_action_at.getTime() + 24 * 60 * 60 * 1000); + await this.manager.updateByName(provider, { + next_action: 'nothing (done)', + ingestion_completed_at: new Date(), + next_action_at, + status: 'resting', + }); + res.json({ + success: true, + message: `${provider}: Current ingestion canceled.`, + }); + } else { + const providers: string[] = await this.manager.listProviders(); + if (providers.includes(provider)) { + this.logger.debug(`${provider} - Ingestion record found`); + res.json({ + success: true, + message: 'Provider is currently restarting, please wait.', + }); + } else { + res.status(404).json({ + success: false, + message: `Provider '${provider}' not found`, + }); + } + } + }); + + // Wipe out all ingestion records for the provider and pause for 24 hours + router.delete(PROVIDER_BASE_PATH, async (req, res) => { + const { provider } = req.params; + const result = await this.manager.purgeAndResetProvider(provider); + res.json(result); + }); + + // Get the ingestion marks for the current cycle + router.get(`${PROVIDER_BASE_PATH}/marks`, async (req, res) => { + const { provider } = req.params; + const record = await this.manager.getCurrentIngestionRecord(provider); + if (record) { + const id = record.id; + const records = await this.manager.getAllMarks(id); + res.json({ success: true, records }); + } else { + const providers: string[] = await this.manager.listProviders(); + if (providers.includes(provider)) { + this.logger.debug(`${provider} - Ingestion record found`); + res.json({ + success: true, + message: 'No records yet (provider is restarting)', + }); + } else { + this.logger.error( + `${provider} - No ingestion record found in the database!`, + ); + res.status(404).json({ + success: false, + status: {}, + last_error: `Provider '${provider}' not found`, + }); + } + } + }); + + router.delete(`${PROVIDER_BASE_PATH}/marks`, async (req, res) => { + const { provider } = req.params; + const deletions = await this.manager.clearFinishedIngestions(provider); + + res.json({ + success: true, + message: `Expired marks for provider '${provider}' removed.`, + deletions, + }); + }); + + router.use(errorHandler()); + + return router; + } +} diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/service/IncrementalCatalogBuilder.ts b/plugins/catalog-backend-module-incremental-ingestion/src/service/IncrementalCatalogBuilder.ts index d6bc5549aa..6d2b3e2be5 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/service/IncrementalCatalogBuilder.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/service/IncrementalCatalogBuilder.ts @@ -24,7 +24,7 @@ import { Knex } from 'knex'; import { IncrementalIngestionEngine } from '../engine/IncrementalIngestionEngine'; import { applyDatabaseMigrations } from '../database/migrations'; import { IncrementalIngestionDatabaseManager } from '../database/IncrementalIngestionDatabaseManager'; -import { createIncrementalProviderRouter } from '../router/routes'; +import { IncrementalProviderRouter } from '../router/routes'; import { Deferred } from '../util'; /** @public */ @@ -60,10 +60,10 @@ export class IncrementalCatalogBuilder { router: 'IncrementalProviderAdmin', }); - const incrementalAdminRouter = await createIncrementalProviderRouter( + const incrementalAdminRouter = await new IncrementalProviderRouter( this.manager, routerLogger, - ); + ).createRouter(); return { incrementalAdminRouter }; } diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/types.ts b/plugins/catalog-backend-module-incremental-ingestion/src/types.ts index 51a8738500..cd1e63b044 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/types.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/types.ts @@ -27,6 +27,7 @@ import type { DeferredEntity, EntityProviderConnection, } from '@backstage/plugin-catalog-backend'; +import { EventParams } from '@backstage/plugin-events-node'; import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; import type { DurationObjectUnits } from 'luxon'; import type { Logger } from 'winston'; @@ -75,6 +76,37 @@ export interface IncrementalEntityProvider { * @param burst - a function which performs a series of iterations */ around(burst: (context: TContext) => Promise): Promise; + + /** + * If set, the IncrementalEntityProvider will receive and respond to + * events. + * + * This system acts as a wrapper for the Backstage events bus, and + * requires the events backend to function. It does not provide its + * own events backend. See {@link https://github.com/backstage/backstage/tree/master/plugins/events-backend}. + */ + eventHandler?: { + /** + * This method accepts an incoming event for the provider, and + * optionally maps the payload to an object containing a delta + * mutation. + * + * If a valid delta is returned by this method, it will be ingested + * automatically by the provider. + */ + onEvent: (params: EventParams) => + | undefined + | { + added: DeferredEntity[]; + removed: { entityRef: string }[]; + }; + + /** + * This method returns an array of topics for the IncrementalEntityProvider + * to respond to. + */ + supportsEventTopics: () => string[]; + }; } /** diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/defaultTransformers.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/defaultTransformers.ts new file mode 100644 index 0000000000..7a8c4ec463 --- /dev/null +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/defaultTransformers.ts @@ -0,0 +1,159 @@ +/* + * 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 { + MICROSOFT_EMAIL_ANNOTATION, + MICROSOFT_GRAPH_GROUP_ID_ANNOTATION, + MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, + MICROSOFT_GRAPH_USER_ID_ANNOTATION, +} from './constants'; +import { normalizeEntityName } from './helper'; +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; + +/** + * The default implementation of the transformation from a graph organization + * entry to a Group entity. + * + * @public + */ +export async function defaultOrganizationTransformer( + organization: MicrosoftGraph.Organization, +): Promise { + if (!organization.id || !organization.displayName) { + return undefined; + } + + const name = normalizeEntityName(organization.displayName!); + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: name, + description: organization.displayName!, + annotations: { + [MICROSOFT_GRAPH_TENANT_ID_ANNOTATION]: organization.id!, + }, + }, + spec: { + type: 'root', + profile: { + displayName: organization.displayName!, + }, + children: [], + }, + }; +} + +function extractGroupName(group: MicrosoftGraph.Group): string { + if (group.securityEnabled) { + return group.displayName as string; + } + return (group.mailNickname || group.displayName) as string; +} + +/** + * The default implementation of the transformation from a graph group entry to + * a Group entity. + * + * @public + */ +export async function defaultGroupTransformer( + group: MicrosoftGraph.Group, + groupPhoto?: string, +): Promise { + if (!group.id || !group.displayName) { + return undefined; + } + + const name = normalizeEntityName(extractGroupName(group)); + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: name, + annotations: { + [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, + }, + }, + spec: { + type: 'team', + profile: {}, + children: [], + }, + }; + + if (group.description) { + entity.metadata.description = group.description; + } + if (group.displayName) { + entity.spec.profile!.displayName = group.displayName; + } + if (group.mail) { + entity.spec.profile!.email = group.mail; + } + if (groupPhoto) { + entity.spec.profile!.picture = groupPhoto; + } + + return entity; +} + +/** + * The default implementation of the transformation from a graph user entry to + * a User entity. + * + * @public + */ +export async function defaultUserTransformer( + user: MicrosoftGraph.User, + userPhoto?: string, +): Promise { + if (!user.id || !user.displayName || !user.mail) { + return undefined; + } + + const name = normalizeEntityName(user.mail); + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name, + annotations: { + [MICROSOFT_EMAIL_ANNOTATION]: user.mail!, + [MICROSOFT_GRAPH_USER_ID_ANNOTATION]: user.id!, + }, + }, + spec: { + profile: { + displayName: user.displayName!, + email: user.mail!, + + // TODO: Additional fields? + // jobTitle: user.jobTitle || undefined, + // officeLocation: user.officeLocation || undefined, + // mobilePhone: user.mobilePhone || undefined, + }, + memberOf: [], + }, + }; + + if (userPhoto) { + entity.spec.profile!.picture = userPhoto; + } + + return entity; +} diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts index e9ab49f7d1..37dcffec22 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts @@ -29,8 +29,8 @@ export { defaultGroupTransformer, defaultOrganizationTransformer, defaultUserTransformer, - readMicrosoftGraphOrg, -} from './read'; +} from './defaultTransformers'; +export { readMicrosoftGraphOrg } from './read'; export type { GroupTransformer, OrganizationTransformer, diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts index a537526a9e..bafb2ecaad 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts @@ -71,16 +71,41 @@ describe('read microsoft graph', () => { afterEach(() => jest.resetAllMocks()); + async function* getExampleUsers() { + yield { + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }; + } + async function getExampleUserProfile() { + return { + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }; + } + async function* getExampleGroups() { + yield { + id: 'groupid', + displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', + }; + } + async function* getExampleGroupMembers(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.group', + id: 'childgroupid', + }; + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid', + }; + } + describe('readMicrosoftGraphUsers', () => { it('should read users', async () => { - async function* getExampleUsers() { - yield { - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }; - } - client.getUsers.mockImplementation(getExampleUsers); client.getUserPhotoWithSizeLimit.mockResolvedValue( 'data:image/jpeg;base64,...', @@ -126,14 +151,6 @@ describe('read microsoft graph', () => { }); it('should read users with advanced query mode', async () => { - async function* getExampleUsers() { - yield { - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }; - } - client.getUsers.mockImplementation(getExampleUsers); client.getUserPhotoWithSizeLimit.mockResolvedValue( 'data:image/jpeg;base64,...', @@ -180,14 +197,6 @@ describe('read microsoft graph', () => { }); it('should read users with userExpand and custom transformer', async () => { - async function* getExampleUsers() { - yield { - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }; - } - client.getUsers.mockImplementation(getExampleUsers); client.getUserPhotoWithSizeLimit.mockResolvedValue( 'data:image/jpeg;base64,...', @@ -232,34 +241,10 @@ describe('read microsoft graph', () => { describe('readMicrosoftGraphUsersInGroups', () => { it('should read users from Groups', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); - client.getUserProfile.mockResolvedValue({ - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }); + client.getUserProfile.mockResolvedValue(getExampleUserProfile()); client.getUserPhotoWithSizeLimit.mockResolvedValue( 'data:image/jpeg;base64,...', ); @@ -311,26 +296,6 @@ describe('read microsoft graph', () => { }); it('should read users from Groups with advanced query mode', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); @@ -391,34 +356,9 @@ describe('read microsoft graph', () => { }); it('should read users with userExpand, groupExpand and custom transformer', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); - - client.getUserProfile.mockResolvedValue({ - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }); + client.getUserProfile.mockResolvedValue(getExampleUserProfile()); client.getUserPhotoWithSizeLimit.mockResolvedValue( 'data:image/jpeg;base64,...', ); @@ -524,26 +464,6 @@ describe('read microsoft graph', () => { describe('readMicrosoftGraphGroups', () => { it('should read groups', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); client.getOrganization.mockResolvedValue({ @@ -619,26 +539,6 @@ describe('read microsoft graph', () => { }); it('should read groups with advanced query mode', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); client.getOrganization.mockResolvedValue({ @@ -715,26 +615,6 @@ describe('read microsoft graph', () => { }); it('should read groups with groupExpand', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); client.getOrganization.mockResolvedValue({ @@ -812,29 +692,6 @@ describe('read microsoft graph', () => { }); it('should read security groups', async () => { - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - mailNickname: 'df546d53-4f5f-4462-b371-d4a855787047', - mailEnabled: false, - securityEnabled: true, - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', - }; - } - client.getGroups.mockImplementation(getExampleGroups); client.getGroupMembers.mockImplementation(getExampleGroupMembers); client.getOrganization.mockResolvedValue({ @@ -994,53 +851,21 @@ describe('read microsoft graph', () => { }); describe('readMicrosoftGraphOrg', () => { - async function* getExampleUsers() { - yield { - id: 'userid', - displayName: 'User Name', - mail: 'user.name@example.com', - }; - } - async function* getExampleUsersEmail() { yield { mail: 'user.name@example.com', }; } - async function getExampleUserProfile(userId: string) { + function getExampleOrg() { return { - id: userId, - displayName: 'User Name', - mail: 'user.name@example.com', - }; - } - - async function* getExampleGroups() { - yield { - id: 'groupid', - displayName: 'Group Name', - description: 'Group Description', - mail: 'group@example.com', - }; - } - - async function* getExampleGroupMembers(): AsyncIterable { - yield { - '@odata.type': '#microsoft.graph.group', - id: 'childgroupid', - }; - yield { - '@odata.type': '#microsoft.graph.user', - id: 'userid', + id: 'tenantid', + displayName: 'Organization Name', }; } it('should read all users if no filter provided', async () => { - client.getOrganization.mockResolvedValue({ - id: 'tenantid', - displayName: 'Organization Name', - }); + client.getOrganization.mockResolvedValue(getExampleOrg()); client.getUsers.mockImplementation(getExampleUsers); client.getUserPhotoWithSizeLimit.mockResolvedValue( @@ -1075,10 +900,7 @@ describe('read microsoft graph', () => { }); it('should read users using userExpand and userFilter', async () => { - client.getOrganization.mockResolvedValue({ - id: 'tenantid', - displayName: 'Organization Name', - }); + client.getOrganization.mockResolvedValue(getExampleOrg()); client.getUsers.mockImplementation(getExampleUsers); client.getUserPhotoWithSizeLimit.mockResolvedValue( diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index 0ed411bfea..6699af7764 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -19,69 +19,25 @@ import { stringifyEntityRef, UserEntity, } from '@backstage/catalog-model'; -import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; import { MicrosoftGraphClient } from './client'; import { - MICROSOFT_EMAIL_ANNOTATION, MICROSOFT_GRAPH_GROUP_ID_ANNOTATION, MICROSOFT_GRAPH_TENANT_ID_ANNOTATION, MICROSOFT_GRAPH_USER_ID_ANNOTATION, } from './constants'; -import { normalizeEntityName } from './helper'; import { buildMemberOf, buildOrgHierarchy } from './org'; import { GroupTransformer, OrganizationTransformer, UserTransformer, } from './types'; - -/** - * The default implementation of the transformation from a graph user entry to - * a User entity. - * - * @public - */ -export async function defaultUserTransformer( - user: MicrosoftGraph.User, - userPhoto?: string, -): Promise { - if (!user.id || !user.displayName || !user.mail) { - return undefined; - } - - const name = normalizeEntityName(user.mail); - const entity: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name, - annotations: { - [MICROSOFT_EMAIL_ANNOTATION]: user.mail!, - [MICROSOFT_GRAPH_USER_ID_ANNOTATION]: user.id!, - }, - }, - spec: { - profile: { - displayName: user.displayName!, - email: user.mail!, - - // TODO: Additional fields? - // jobTitle: user.jobTitle || undefined, - // officeLocation: user.officeLocation || undefined, - // mobilePhone: user.mobilePhone || undefined, - }, - memberOf: [], - }, - }; - - if (userPhoto) { - entity.spec.profile!.picture = userPhoto; - } - - return entity; -} +import { + defaultGroupTransformer, + defaultOrganizationTransformer, + defaultUserTransformer, +} from './defaultTransformers'; export async function readMicrosoftGraphUsers( client: MicrosoftGraphClient, @@ -237,40 +193,6 @@ export async function readMicrosoftGraphUsersInGroups( return { users }; } -/** - * The default implementation of the transformation from a graph organization - * entry to a Group entity. - * - * @public - */ -export async function defaultOrganizationTransformer( - organization: MicrosoftGraph.Organization, -): Promise { - if (!organization.id || !organization.displayName) { - return undefined; - } - - const name = normalizeEntityName(organization.displayName!); - return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: name, - description: organization.displayName!, - annotations: { - [MICROSOFT_GRAPH_TENANT_ID_ANNOTATION]: organization.id!, - }, - }, - spec: { - type: 'root', - profile: { - displayName: organization.displayName!, - }, - children: [], - }, - }; -} - export async function readMicrosoftGraphOrganization( client: MicrosoftGraphClient, tenantId: string, @@ -286,60 +208,6 @@ export async function readMicrosoftGraphOrganization( return { rootGroup }; } -function extractGroupName(group: MicrosoftGraph.Group): string { - if (group.securityEnabled) { - return group.displayName as string; - } - return (group.mailNickname || group.displayName) as string; -} - -/** - * The default implementation of the transformation from a graph group entry to - * a Group entity. - * - * @public - */ -export async function defaultGroupTransformer( - group: MicrosoftGraph.Group, - groupPhoto?: string, -): Promise { - if (!group.id || !group.displayName) { - return undefined; - } - - const name = normalizeEntityName(extractGroupName(group)); - const entity: GroupEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: name, - annotations: { - [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, - }, - }, - spec: { - type: 'team', - profile: {}, - children: [], - }, - }; - - if (group.description) { - entity.metadata.description = group.description; - } - if (group.displayName) { - entity.spec.profile!.displayName = group.displayName; - } - if (group.mail) { - entity.spec.profile!.email = group.mail; - } - if (groupPhoto) { - entity.spec.profile!.picture = groupPhoto; - } - - return entity; -} - export async function readMicrosoftGraphGroups( client: MicrosoftGraphClient, tenantId: string, diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 9528c55326..095b6832a4 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -194,7 +194,9 @@ export class EntityLifecycleFilter implements EntityFilter { } // @public (undocumented) -export const EntityLifecyclePicker: () => JSX.Element | null; +export const EntityLifecyclePicker: (props: { + initialFilter?: string[]; +}) => JSX.Element | null; // @public export const EntityListContext: React_2.Context< diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index ef77f7615f..3ee610ddaf 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -210,4 +210,21 @@ describe('', () => { lifecycles: undefined, }); }); + it('responds to initialFilter prop', () => { + const updateFilters = jest.fn(); + render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['production']), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx index b941f94531..77925598e1 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx @@ -47,7 +47,8 @@ const icon = ; const checkedIcon = ; /** @public */ -export const EntityLifecyclePicker = () => { +export const EntityLifecyclePicker = (props: { initialFilter?: string[] }) => { + const { initialFilter = [] } = props; const classes = useStyles(); const { updateFilters, @@ -64,7 +65,7 @@ export const EntityLifecyclePicker = () => { const [selectedLifecycles, setSelectedLifecycles] = useState( queryParamLifecycles.length ? queryParamLifecycles - : filters.lifecycles?.values ?? [], + : filters.lifecycles?.values ?? initialFilter, ); // Set selected lifecycles on query parameter updates; this happens at initial page load and from diff --git a/plugins/github-issues/README.md b/plugins/github-issues/README.md index d35f2c5d3f..f6b383bfcc 100644 --- a/plugins/github-issues/README.md +++ b/plugins/github-issues/README.md @@ -25,8 +25,8 @@ After installation, the plugin can be used as a Card or as a Page. ```typescript import { - GitHubIssuesCard, - GitHubIssuesPage, + GithubIssuesCard, + GithubIssuesPage, } from '@backstage/plugin-github-issues'; // To use as a page Plugin needs to be wrapped in EntityLayout.Route @@ -34,9 +34,9 @@ const RenderGitHubIssuesPage = () => ( - + - + ); @@ -46,17 +46,17 @@ const RenderGitHubIssuesCard = () => ( - + - + ); ``` ## Configuration -Both `GitHubIssuesPage` and `GitHubIssuesCard` provide default configuration. It is ready to use out of the box. +Both `GithubIssuesPage` and `GithubIssuesCard` provide default configuration. It is ready to use out of the box. However, you can configure the plugin with props: - `itemsPerPage: number = 10` - Issues in the list are paginated, number of issues on a single page is controlled with this prop @@ -67,7 +67,7 @@ However, you can configure the plugin with props: ### `filterBy` and `orderBy` example ```ts -; // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index f723a46d16..f5701d49fe 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -127,7 +127,7 @@ export type FetchResponse = | ServiceFetchResponse | ConfigMapFetchResponse | DeploymentFetchResponse - | LimitRangeFetchReponse + | LimitRangeFetchResponse | ReplicaSetsFetchResponse | HorizontalPodAutoscalersFetchResponse | JobsFetchResponse @@ -169,7 +169,7 @@ export interface ReplicaSetsFetchResponse { } /** @public */ -export interface LimitRangeFetchReponse { +export interface LimitRangeFetchResponse { type: 'limitranges'; resources: Array; } diff --git a/plugins/scaffolder-backend-module-cookiecutter/api-report.md b/plugins/scaffolder-backend-module-cookiecutter/api-report.md index e55c6368c4..76f2ced66f 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/api-report.md +++ b/plugins/scaffolder-backend-module-cookiecutter/api-report.md @@ -8,7 +8,7 @@ import { ContainerRunner } from '@backstage/backend-common'; import { JsonObject } from '@backstage/types'; import { ScmIntegrations } from '@backstage/integration'; -import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { UrlReader } from '@backstage/backend-common'; // @public diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 6a1ee7fd3b..57457dc051 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -28,6 +28,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-backend": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "command-exists": "^1.2.9", "fs-extra": "10.1.0", diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts index 15e5d25183..f8b049f95f 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts @@ -27,7 +27,7 @@ import os from 'os'; import { PassThrough } from 'stream'; import { createFetchCookiecutterAction } from './cookiecutter'; import { join } from 'path'; -import type { ActionContext } from '@backstage/plugin-scaffolder-backend'; +import type { ActionContext } from '@backstage/plugin-scaffolder-node'; const executeShellCommand = jest.fn(); const commandExists = jest.fn(); diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts index 7575627506..5c69b9dce2 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts @@ -27,10 +27,10 @@ import fs from 'fs-extra'; import path, { resolve as resolvePath } from 'path'; import { Writable } from 'stream'; import { - createTemplateAction, fetchContents, executeShellCommand, } from '@backstage/plugin-scaffolder-backend'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; export class CookiecutterRunner { private readonly containerRunner: ContainerRunner; diff --git a/plugins/scaffolder-backend-module-rails/api-report.md b/plugins/scaffolder-backend-module-rails/api-report.md index 551da91314..c91daa1344 100644 --- a/plugins/scaffolder-backend-module-rails/api-report.md +++ b/plugins/scaffolder-backend-module-rails/api-report.md @@ -6,7 +6,7 @@ import { ContainerRunner } from '@backstage/backend-common'; import { JsonObject } from '@backstage/types'; import { ScmIntegrations } from '@backstage/integration'; -import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { UrlReader } from '@backstage/backend-common'; // @public diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index 408db6d559..98a5db9de8 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -28,6 +28,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-backend": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "command-exists": "^1.2.9", "fs-extra": "^10.0.1" diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts index 4a5bd7dfd1..2a8a2554a2 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -19,10 +19,8 @@ import { JsonObject } from '@backstage/types'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import fs from 'fs-extra'; -import { - createTemplateAction, - fetchContents, -} from '@backstage/plugin-scaffolder-backend'; +import { fetchContents } from '@backstage/plugin-scaffolder-backend'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { resolve as resolvePath } from 'path'; import { RailsNewRunner } from './railsNewRunner'; diff --git a/plugins/scaffolder-backend-module-sentry/api-report.md b/plugins/scaffolder-backend-module-sentry/api-report.md index 8b0c229e03..ef09bdebc0 100644 --- a/plugins/scaffolder-backend-module-sentry/api-report.md +++ b/plugins/scaffolder-backend-module-sentry/api-report.md @@ -4,7 +4,7 @@ ```ts import { Config } from '@backstage/config'; -import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; // @public export function createSentryCreateProjectAction(options: { diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index 8eaf5c398d..c99308c5e0 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -25,7 +25,7 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", - "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index bfcc8f016b..85a74eda07 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; diff --git a/plugins/scaffolder-backend-module-yeoman/api-report.md b/plugins/scaffolder-backend-module-yeoman/api-report.md index 0f8747e592..562fe52ac2 100644 --- a/plugins/scaffolder-backend-module-yeoman/api-report.md +++ b/plugins/scaffolder-backend-module-yeoman/api-report.md @@ -4,7 +4,7 @@ ```ts import { JsonObject } from '@backstage/types'; -import { TemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; // @public export function createRunYeomanAction(): TemplateAction<{ diff --git a/plugins/scaffolder-backend-module-yeoman/package.json b/plugins/scaffolder-backend-module-yeoman/package.json index 2d4d7e8702..071f7268ed 100644 --- a/plugins/scaffolder-backend-module-yeoman/package.json +++ b/plugins/scaffolder-backend-module-yeoman/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@backstage/config": "workspace:^", - "@backstage/plugin-scaffolder-backend": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "winston": "^3.2.1", "yeoman-environment": "^3.9.1" diff --git a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts index ee5e8449f7..146c29f4ac 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts @@ -22,7 +22,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import os from 'os'; import { PassThrough } from 'stream'; import { createRunYeomanAction } from './yeoman'; -import type { ActionContext } from '@backstage/plugin-scaffolder-backend'; +import type { ActionContext } from '@backstage/plugin-scaffolder-node'; import { JsonObject } from '@backstage/types'; describe('run:yeoman', () => { diff --git a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts index f7b0c54b41..89ea10b6a6 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.ts @@ -15,7 +15,7 @@ */ import { JsonObject } from '@backstage/types'; -import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { yeomanRun } from './yeomanRun'; /** diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 1a3c1db03f..22cc0cae20 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { ActionContext as ActionContext_2 } from '@backstage/plugin-scaffolder-node'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CatalogProcessor } from '@backstage/plugin-catalog-backend'; @@ -24,38 +25,23 @@ import { Observable } from '@backstage/types'; import { Octokit } from 'octokit'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; import { SpawnOptionsWithoutStdio } from 'child_process'; +import { TaskSecrets as TaskSecrets_2 } from '@backstage/plugin-scaffolder-node'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskSpecV1beta3 } from '@backstage/plugin-scaffolder-common'; -import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node'; import { UrlReader } from '@backstage/backend-common'; -import { UserEntity } from '@backstage/catalog-model'; import { Writable } from 'stream'; -// @public -export type ActionContext = { - logger: Logger; - logStream: Writable; - secrets?: TaskSecrets; - workspacePath: string; - input: Input; - output(name: string, value: JsonValue): void; - createTemporaryDirectory(): Promise; - templateInfo?: TemplateInfo; - isDryRun?: boolean; - user?: { - entity?: UserEntity; - ref?: string; - }; -}; +// @public @deprecated (undocumented) +export type ActionContext = ActionContext_2; // @public export const createBuiltinActions: ( options: CreateBuiltInActionsOptions, -) => TemplateAction[]; +) => TemplateAction_2[]; // @public export interface CreateBuiltInActionsOptions { @@ -72,7 +58,7 @@ export interface CreateBuiltInActionsOptions { export function createCatalogRegisterAction(options: { catalogClient: CatalogApi; integrations: ScmIntegrations; -}): TemplateAction< +}): TemplateAction_2< | { catalogInfoUrl: string; optional?: boolean | undefined; @@ -85,13 +71,13 @@ export function createCatalogRegisterAction(options: { >; // @public -export function createCatalogWriteAction(): TemplateAction<{ +export function createCatalogWriteAction(): TemplateAction_2<{ filePath?: string | undefined; entity: Entity; }>; // @public -export function createDebugLogAction(): TemplateAction<{ +export function createDebugLogAction(): TemplateAction_2<{ message?: string | undefined; listWorkspace?: boolean | undefined; }>; @@ -99,7 +85,7 @@ export function createDebugLogAction(): TemplateAction<{ // @public export function createFetchCatalogEntityAction(options: { catalogClient: CatalogApi; -}): TemplateAction<{ +}): TemplateAction_2<{ entityRef: string; optional?: boolean | undefined; }>; @@ -108,7 +94,7 @@ export function createFetchCatalogEntityAction(options: { export function createFetchPlainAction(options: { reader: UrlReader; integrations: ScmIntegrations; -}): TemplateAction<{ +}): TemplateAction_2<{ url: string; targetPath?: string | undefined; }>; @@ -119,7 +105,7 @@ export function createFetchTemplateAction(options: { integrations: ScmIntegrations; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; -}): TemplateAction<{ +}): TemplateAction_2<{ url: string; targetPath?: string | undefined; values: any; @@ -127,15 +113,16 @@ export function createFetchTemplateAction(options: { copyWithoutRender?: string[] | undefined; copyWithoutTemplating?: string[] | undefined; cookiecutterCompat?: boolean | undefined; + replace?: boolean | undefined; }>; // @public -export const createFilesystemDeleteAction: () => TemplateAction<{ +export const createFilesystemDeleteAction: () => TemplateAction_2<{ files: string[]; }>; // @public -export const createFilesystemRenameAction: () => TemplateAction<{ +export const createFilesystemRenameAction: () => TemplateAction_2<{ files: Array<{ from: string; to: string; @@ -147,7 +134,7 @@ export const createFilesystemRenameAction: () => TemplateAction<{ export function createGithubActionsDispatchAction(options: { integrations: ScmIntegrations; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; workflowId: string; branchOrTagName: string; @@ -163,7 +150,7 @@ export function createGithubActionsDispatchAction(options: { export function createGithubIssuesLabelAction(options: { integrations: ScmIntegrationRegistry; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; number: number; labels: string[]; @@ -193,7 +180,7 @@ export type CreateGithubPullRequestClientFactoryInput = { export function createGithubRepoCreateAction(options: { integrations: ScmIntegrationRegistry; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; homepage?: string | undefined; @@ -260,7 +247,7 @@ export function createGithubRepoPushAction(options: { integrations: ScmIntegrationRegistry; config: Config; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; @@ -299,7 +286,7 @@ export function createGithubWebhookAction(options: { integrations: ScmIntegrationRegistry; defaultWebhookSecret?: string; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; webhookUrl: string; webhookSecret?: string | undefined; @@ -314,7 +301,7 @@ export function createGithubWebhookAction(options: { export function createPublishAzureAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; @@ -329,7 +316,7 @@ export function createPublishAzureAction(options: { export function createPublishBitbucketAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; @@ -346,7 +333,7 @@ export function createPublishBitbucketAction(options: { export function createPublishBitbucketCloudAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; @@ -359,7 +346,7 @@ export function createPublishBitbucketCloudAction(options: { export function createPublishBitbucketServerAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; defaultBranch?: string | undefined; @@ -376,7 +363,7 @@ export function createPublishBitbucketServerAction(options: { export function createPublishGerritAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description: string; defaultBranch?: string | undefined; @@ -390,7 +377,7 @@ export function createPublishGerritAction(options: { export function createPublishGerritReviewAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; branch?: string | undefined; sourcePath?: string | undefined; @@ -404,7 +391,7 @@ export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; config: Config; githubCredentialsProvider?: GithubCredentialsProvider; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; description?: string | undefined; homepage?: string | undefined; @@ -477,7 +464,7 @@ export const createPublishGithubPullRequestAction: ({ integrations, githubCredentialsProvider, clientFactory, -}: CreateGithubPullRequestActionOptions) => TemplateAction<{ +}: CreateGithubPullRequestActionOptions) => TemplateAction_2<{ title: string; branchName: string; description: string; @@ -494,7 +481,7 @@ export const createPublishGithubPullRequestAction: ({ export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; config: Config; -}): TemplateAction<{ +}): TemplateAction_2<{ repoUrl: string; defaultBranch?: string | undefined; repoVisibility?: 'internal' | 'private' | 'public' | undefined; @@ -510,7 +497,7 @@ export function createPublishGitlabAction(options: { // @public export const createPublishGitlabMergeRequestAction: (options: { integrations: ScmIntegrationRegistry; -}) => TemplateAction<{ +}) => TemplateAction_2<{ repoUrl: string; title: string; description: string; @@ -527,10 +514,10 @@ export const createPublishGitlabMergeRequestAction: (options: { // @public export function createRouter(options: RouterOptions): Promise; -// @public +// @public @deprecated (undocumented) export const createTemplateAction: ( - templateAction: TemplateAction, -) => TemplateAction; + templateAction: TemplateAction_2, +) => TemplateAction_2; // @public export type CreateWorkerOptions = { @@ -547,7 +534,7 @@ export type CreateWorkerOptions = { // @public export interface CurrentClaimedTask { createdBy?: string; - secrets?: TaskSecrets; + secrets?: TaskSecrets_2; spec: TaskSpec; taskId: string; } @@ -628,7 +615,7 @@ export type OctokitWithPullRequestPluginClient = Octokit & { // @public export interface RouterOptions { // (undocumented) - actions?: TemplateAction[]; + actions?: TemplateAction_2[]; // (undocumented) additionalTemplateFilters?: Record; // (undocumented) @@ -686,7 +673,7 @@ export const scaffolderPlugin: ( // @alpha export type ScaffolderPluginOptions = { - actions?: TemplateAction[]; + actions?: TemplateAction_2[]; taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; @@ -701,7 +688,7 @@ export type SerializedTask = { createdAt: string; lastHeartbeatAt?: string; createdBy?: string; - secrets?: TaskSecrets; + secrets?: TaskSecrets_2; }; // @public @@ -738,7 +725,7 @@ export interface TaskBroker { // @public export type TaskBrokerDispatchOptions = { spec: TaskSpec; - secrets?: TaskSecrets; + secrets?: TaskSecrets_2; createdBy?: string; }; @@ -765,7 +752,7 @@ export interface TaskContext { // (undocumented) isDryRun?: boolean; // (undocumented) - secrets?: TaskSecrets; + secrets?: TaskSecrets_2; // (undocumented) spec: TaskSpec; } @@ -792,15 +779,13 @@ export class TaskManager implements TaskContext { // (undocumented) getWorkspaceName(): Promise; // (undocumented) - get secrets(): TaskSecrets | undefined; + get secrets(): TaskSecrets_2 | undefined; // (undocumented) get spec(): TaskSpecV1beta3; } -// @public -export type TaskSecrets = Record & { - backstageToken?: string; -}; +// @public @deprecated (undocumented) +export type TaskSecrets = TaskSecrets_2; // @public export type TaskStatus = @@ -852,7 +837,7 @@ export interface TaskStore { export type TaskStoreCreateTaskOptions = { spec: TaskSpec; createdBy?: string; - secrets?: TaskSecrets; + secrets?: TaskSecrets_2; }; // @public @@ -889,30 +874,18 @@ export class TaskWorker { start(): void; } -// @public (undocumented) -export type TemplateAction = { - id: string; - description?: string; - examples?: { - description: string; - example: string; - }[]; - supportsDryRun?: boolean; - schema?: { - input?: Schema; - output?: Schema; - }; - handler: (ctx: ActionContext) => Promise; -}; +// @public @deprecated (undocumented) +export type TemplateAction = + TemplateAction_2; // @public export class TemplateActionRegistry { // (undocumented) - get(actionId: string): TemplateAction; + get(actionId: string): TemplateAction_2; // (undocumented) - list(): TemplateAction[]; + list(): TemplateAction_2[]; // (undocumented) - register(action: TemplateAction): void; + register(action: TemplateAction_2): void; } // @public (undocumented) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 8a98ea4a3d..93938e23e1 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -46,6 +46,7 @@ "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "@gitbeaker/core": "^35.6.0", "@gitbeaker/node": "^35.1.0", diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 71472abda6..43b8d30f99 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -13,20 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createBackendPlugin, coreServices, - createExtensionPoint, } from '@backstage/backend-plugin-api'; import { loggerToWinstonLogger } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { + scaffolderActionsExtensionPoint, + ScaffolderActionsExtensionPoint, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; import { TemplateFilter, TemplateGlobal } from './lib'; -import { createBuiltinActions, TaskBroker, TemplateAction } from './scaffolder'; +import { createBuiltinActions, TaskBroker } from './scaffolder'; import { createRouter } from './service/router'; /** * Catalog plugin options + * * @alpha */ export type ScaffolderPluginOptions = { @@ -37,37 +43,23 @@ export type ScaffolderPluginOptions = { additionalTemplateGlobals?: Record; }; -/** - * @alpha - * TODO: MOVE to scaffolder-node. - */ -interface ScaffolderActionsExtensionPoint { - addActions(...actions: TemplateAction[]): void; -} - class ScaffolderActionsExtensionPointImpl implements ScaffolderActionsExtensionPoint { #actions = new Array>(); + addActions(...actions: TemplateAction[]): void { this.#actions.push(...actions); } + get actions() { return this.#actions; } } -/** - * @alpha - * TODO: MOVE to scaffolder-node. - */ -export const scaffolderActionsExtensionPoint = - createExtensionPoint({ - id: 'scaffolder.actions', - }); - /** * Catalog plugin + * * @alpha */ export const scaffolderPlugin = createBackendPlugin( @@ -75,6 +67,7 @@ export const scaffolderPlugin = createBackendPlugin( id: 'scaffolder', register(env) { const actionsExtensions = new ScaffolderActionsExtensionPointImpl(); + env.registerExtensionPoint( scaffolderActionsExtensionPoint, actionsExtensions, diff --git a/plugins/scaffolder-backend/src/deprecated.ts b/plugins/scaffolder-backend/src/deprecated.ts new file mode 100644 index 0000000000..f8358b9071 --- /dev/null +++ b/plugins/scaffolder-backend/src/deprecated.ts @@ -0,0 +1,49 @@ +/* + * 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 { + ActionContext as ActionContextNode, + createTemplateAction as createTemplateActionNode, + TaskSecrets as TaskSecretsNode, + TemplateAction as TemplateActionNode, +} from '@backstage/plugin-scaffolder-node'; +import { JsonObject } from '@backstage/types'; + +/** + * @public + * @deprecated Import from {@link @backstage/plugin-scaffolder-node#ActionContext} instead + */ +export type ActionContext = + ActionContextNode; + +/** + * @public + * @deprecated Use `createTemplateAction` from `@backstage/plugin-scaffolder-node` instead + */ +export const createTemplateAction = createTemplateActionNode; + +/** + * @public + * @deprecated Use `TaskSecrets` from `@backstage/plugin-scaffolder-node` instead + */ +export type TaskSecrets = TaskSecretsNode; + +/** + * @public + * @deprecated Use `TemplateAction` from `@backstage/plugin-scaffolder-node` instead + */ +export type TemplateAction = + TemplateActionNode; diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index 985ada405e..eb7a831bd0 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -27,3 +27,5 @@ export * from './processor'; export * from './extension'; export { scaffolderPlugin } from './ScaffolderPlugin'; export type { ScaffolderPluginOptions } from './ScaffolderPlugin'; + +export * from './deprecated'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index 8a2a00d121..d044d095cd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -16,7 +16,7 @@ import { JsonObject } from '@backstage/types'; import { ConflictError, NotFoundError } from '@backstage/errors'; -import { TemplateAction } from './types'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; /** * Registry of all registered template actions. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts index c45eb62fce..913bf32cce 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts @@ -15,7 +15,7 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import yaml from 'yaml'; const id = 'catalog:fetch'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 48fec3db03..e866eb12ba 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -18,7 +18,7 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { CatalogApi } from '@backstage/catalog-client'; import { stringifyEntityRef } from '@backstage/catalog-model'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import yaml from 'yaml'; const id = 'catalog:register'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index 5257839f2d..5c5a62956f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -15,7 +15,7 @@ */ import fs from 'fs-extra'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import * as yaml from 'yaml'; import { Entity } from '@backstage/catalog-model'; import { resolveSafeChildPath } from '@backstage/backend-common'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 7e954295a1..d83d9c5b07 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -23,6 +23,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { JsonObject } from '@backstage/types'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createCatalogRegisterAction, createCatalogWriteAction, @@ -30,7 +31,6 @@ import { } from './catalog'; import { TemplateFilter, TemplateGlobal } from '../../../lib'; -import { TemplateAction } from '../types'; import { createDebugLogAction } from './debug'; import { createFetchPlainAction, createFetchTemplateAction } from './fetch'; import { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts index 3863623563..481eb9dfdd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -16,7 +16,7 @@ import { readdir, stat } from 'fs-extra'; import { relative, join } from 'path'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import yaml from 'yaml'; const id = 'debug:log'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 0469ffec06..ed37a39a98 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + jest.mock('./helpers'); import os from 'os'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index b3b984f544..c10f696bb9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -17,7 +17,7 @@ import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; import { fetchContents } from './helpers'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; /** * Downloads content and places it in the workspace, or optionally diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 6b03290e1e..15548ffea7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -26,8 +26,11 @@ import { import { ScmIntegrations } from '@backstage/integration'; import { PassThrough } from 'stream'; import { fetchContents } from './helpers'; -import { ActionContext, TemplateAction } from '../../types'; import { createFetchTemplateAction } from './template'; +import { + ActionContext, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; jest.mock('./helpers', () => ({ fetchContents: jest.fn(), @@ -669,4 +672,81 @@ describe('fetch:template', () => { ).resolves.toEqual('test-project: 1234'); }); }); + + describe('with replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + replace: true, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + ...realFiles, + [joinPath(workspacePath, 'target')]: { + 'static-content.txt': 'static-content', + }, + [outputPath]: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + }, + }); + + return Promise.resolve(); + }); + + await action.handler(context); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('overwrites existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('test-project: 1234'); + }); + }); + + describe('without replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + targetPath: './target', + replace: false, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + ...realFiles, + [joinPath(workspacePath, 'target')]: { + 'static-content.txt': 'static-content', + }, + [outputPath]: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + }, + }); + + return Promise.resolve(); + }); + + await action.handler(context); + }); + + it('keeps existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('static-content'); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 10a9d0c055..68c205ce59 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -19,7 +19,7 @@ import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { fetchContents } from './helpers'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import globby from 'globby'; import fs from 'fs-extra'; import { isBinaryFile } from 'isbinaryfile'; @@ -62,6 +62,7 @@ export function createFetchTemplateAction(options: { copyWithoutRender?: string[]; copyWithoutTemplating?: string[]; cookiecutterCompat?: boolean; + replace?: boolean; }>({ id: 'fetch:template', description: @@ -118,6 +119,12 @@ export function createFetchTemplateAction(options: { 'If set, only files with the given extension will be templated. If set to `true`, the default extension `.njk` is used.', type: ['string', 'boolean'], }, + replace: { + title: 'Replace files', + description: + 'If set, replace files in targetPath instead of skipping existing ones.', + type: 'boolean', + }, }, }, }, @@ -261,7 +268,7 @@ export function createFetchTemplateAction(options: { } const outputPath = resolveSafeChildPath(outputDir, localOutputPath); - if (fs.existsSync(outputPath)) { + if (fs.existsSync(outputPath) && !ctx.input.replace) { continue; } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts index 757e9c682e..2f955c16ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createTemplateAction } from '../../createTemplateAction'; + +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { resolveSafeChildPath } from '@backstage/backend-common'; import fs from 'fs-extra'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts index 654ee7b150..c29ba221c4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createTemplateAction } from '../../createTemplateAction'; -import { resolveSafeChildPath } from '@backstage/backend-common'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { resolveSafeChildPath } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import fs from 'fs-extra'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts index 0782ea40cd..3d59e43695 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import { TemplateAction } from '../../types'; -import { createGithubActionsDispatchAction } from './githubActionsDispatch'; - import { ScmIntegrations, DefaultGithubCredentialsProvider, @@ -24,7 +21,9 @@ import { } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { PassThrough } from 'stream'; +import { createGithubActionsDispatchAction } from './githubActionsDispatch'; const mockOctokit = { rest: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index b9d77559e0..cd1593ee9f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { InputError } from '@backstage/errors'; import { GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Octokit } from 'octokit'; -import { createTemplateAction } from '../../createTemplateAction'; import { parseRepoUrl } from '../publish/util'; import { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.test.ts index bc9b797000..a1a0274304 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.test.ts @@ -22,8 +22,8 @@ import { } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { PassThrough } from 'stream'; -import { TemplateAction } from '../../types'; const mockOctokit = { rest: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.ts index 9c849edb6f..2dad0aa33a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubIssuesLabel.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { GithubCredentialsProvider, ScmIntegrationRegistry, } from '@backstage/integration'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { assertError, InputError } from '@backstage/errors'; import { Octokit } from 'octokit'; import { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts index 824db27e84..f15747fd6b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateAction } from '../../types'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; jest.mock('../helpers'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.ts index 835fc79238..b5f779b8a7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.ts @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { InputError } from '@backstage/errors'; import { GithubCredentialsProvider, ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from 'octokit'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { parseRepoUrl } from '../publish/util'; import { createGithubRepoWithCollaboratorsAndTopics, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.test.ts index 24777163d4..517347c733 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateAction } from '../../types'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; jest.mock('../helpers'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.ts index 5a5ffc015c..6e3892cee5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoPush.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Config } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { @@ -20,7 +21,7 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from 'octokit'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { parseRepoUrl } from '../publish/util'; import { getOctokitOptions, initRepoPushAndProtect } from './helpers'; import * as inputProps from './inputProperties'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts index 97fdb3d634..7901210a6e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts @@ -22,8 +22,8 @@ import { } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { PassThrough } from 'stream'; -import { TemplateAction } from '../../types'; const mockOctokit = { rest: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts index 4547ce4492..4d487befc7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { GithubCredentialsProvider, ScmIntegrationRegistry, } from '@backstage/integration'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { emitterEventNames } from '@octokit/webhooks'; import { assertError, InputError } from '@backstage/errors'; import { Octokit } from 'octokit'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index ca5e40bdb0..2d2775572f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + jest.mock('azure-devops-node-api', () => ({ WebApi: jest.fn(), getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 60b13b717d..083c18c94f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -20,7 +20,7 @@ import { initRepoAndPush } from '../helpers'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; /** diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 8f72157fc2..74e5e8c1ab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -19,9 +19,9 @@ import { BitbucketIntegrationConfig, ScmIntegrationRegistry, } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fetch, { Response, RequestInit } from 'node-fetch'; import { initRepoAndPush } from '../helpers'; -import { createTemplateAction } from '../../createTemplateAction'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { Config } from '@backstage/config'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts index 02e29c33de..2c17bf2dbb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketCloud.ts @@ -16,9 +16,9 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fetch, { Response, RequestInit } from 'node-fetch'; import { initRepoAndPush } from '../helpers'; -import { createTemplateAction } from '../../createTemplateAction'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { Config } from '@backstage/config'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts index 8c96c14463..f2f581eadb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts @@ -19,9 +19,9 @@ import { getBitbucketServerRequestOptions, ScmIntegrationRegistry, } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fetch, { Response, RequestInit } from 'node-fetch'; import { initRepoAndPush } from '../helpers'; -import { createTemplateAction } from '../../createTemplateAction'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { Config } from '@backstage/config'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerrit.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerrit.ts index 83653b3052..b5bcc31c62 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerrit.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerrit.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import crypto from 'crypto'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; @@ -21,7 +22,7 @@ import { getGerritRequestOptions, ScmIntegrationRegistry, } from '@backstage/integration'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import fetch, { Response, RequestInit } from 'node-fetch'; import { initRepoAndPush } from '../helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts index 17068654c0..73e74f5cbc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import crypto from 'crypto'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { commitAndPushRepo } from '../helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index c2681ed527..8ead489ea4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateAction } from '../../types'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; jest.mock('../helpers'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index add6fcc79c..185239c73f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Config } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { @@ -20,7 +21,7 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from 'octokit'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { createGithubRepoWithCollaboratorsAndTopics, getOctokitOptions, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts index e6649d1ffe..70fff20446 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts @@ -20,11 +20,14 @@ import { GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; +import { + ActionContext, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; import mockFs from 'mock-fs'; import os from 'os'; import { resolve as resolvePath } from 'path'; import { Writable } from 'stream'; -import { ActionContext, TemplateAction } from '../../types'; import { createPublishGithubPullRequestAction, OctokitWithPullRequestPluginClient, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index d0ee979be3..764354e8ec 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -20,11 +20,11 @@ import { GithubCredentialsProvider, ScmIntegrationRegistry, } from '@backstage/integration'; -import { createTemplateAction } from '../../createTemplateAction'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Octokit } from 'octokit'; import { InputError, CustomErrorBase } from '@backstage/errors'; -import { createPullRequest } from 'octokit-plugin-create-pull-request'; import { resolveSafeChildPath } from '@backstage/backend-common'; +import { createPullRequest } from 'octokit-plugin-create-pull-request'; import { getOctokitOptions } from '../github/helpers'; import { SerializedFile, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts index dc1d26d416..a9c4522dd3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + jest.mock('../helpers'); import { createPublishGitlabAction } from './gitlab'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 53669efd0b..2e7520b77f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -16,10 +16,10 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from '../helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; -import { createTemplateAction } from '../../createTemplateAction'; import { Config } from '@backstage/config'; /** diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.test.ts index d1888fd9d2..c3d0fe58f1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.test.ts @@ -16,11 +16,11 @@ import { getRootLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import mockFs from 'mock-fs'; import os from 'os'; import { resolve as resolvePath } from 'path'; import { Writable } from 'stream'; -import { TemplateAction } from '../../types'; import { createPublishGitlabMergeRequestAction } from './gitlabMergeRequest'; const root = os.platform() === 'win32' ? 'C:\\root' : '/root'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 2a71c9f748..bb27410402 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createTemplateAction } from '../../createTemplateAction'; + +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Gitlab } from '@gitbeaker/node'; import { Types } from '@gitbeaker/core'; import path from 'path'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/index.ts index d5f77b7b6f..9ea0607f06 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/index.ts @@ -16,5 +16,3 @@ export * from './builtin'; export { TemplateActionRegistry } from './TemplateActionRegistry'; -export { createTemplateAction } from './createTemplateAction'; -export type { ActionContext, TemplateAction } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts index 6f11d3b6e8..c02375554c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts @@ -14,8 +14,9 @@ * limitations under the License. */ +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { JsonObject } from '@backstage/types'; -import { TemplateAction, TemplateActionRegistry } from '../actions'; +import { TemplateActionRegistry } from '../actions'; /** @internal */ export class DecoratedActionsRegistry extends TemplateActionRegistry { diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index 17f9092bff..1929e3b8ac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -26,12 +26,15 @@ import { serializeDirectoryContents, } from '../../lib/files'; import { TemplateFilter, TemplateGlobal } from '../../lib/templating'; -import { createTemplateAction, TemplateActionRegistry } from '../actions'; +import { TemplateActionRegistry } from '../actions'; import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner'; -import { TaskSecrets } from '../tasks/types'; import { DecoratedActionsRegistry } from './DecoratedActionsRegistry'; import fs from 'fs-extra'; import { resolveSafeChildPath } from '@backstage/backend-common'; +import { + createTemplateAction, + TaskSecrets, +} from '@backstage/plugin-scaffolder-node'; interface DryRunInput { spec: TaskSpec; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 24a35078e2..453cfae83b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -22,8 +22,9 @@ import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner'; import { TemplateActionRegistry } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { TaskContext, TaskSecrets } from './types'; +import { TaskContext } from './types'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; import { UserEntity } from '@backstage/catalog-model'; // The Stream module is lazy loaded, so make sure it's in the module cache before mocking fs diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 5893a88485..516abe79e4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -26,7 +26,7 @@ import { PassThrough } from 'stream'; import { generateExampleOutput, isTruthy } from './helper'; import { validate as validateJsonSchema } from 'jsonschema'; import { parseRepoUrl } from '../actions/builtin/publish/util'; -import { TemplateAction, TemplateActionRegistry } from '../actions'; +import { TemplateActionRegistry } from '../actions'; import { TemplateFilter, SecureTemplater, @@ -38,6 +38,7 @@ import { TaskSpecV1beta3, TaskStep, } from '@backstage/plugin-scaffolder-common'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { UserEntity } from '@backstage/catalog-model'; import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts index 5eeb73711e..2255798d5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts @@ -17,9 +17,10 @@ import { getVoidLogger, DatabaseManager } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { StorageTaskBroker, TaskManager } from './StorageTaskBroker'; -import { TaskSecrets, SerializedTaskEvent } from './types'; +import { SerializedTaskEvent } from './types'; async function createStore(): Promise { const manager = DatabaseManager.fromConfig( diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index 7a015c111f..d01351d8ae 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -15,6 +15,7 @@ */ import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; import { JsonObject, Observable } from '@backstage/types'; import { Logger } from 'winston'; import ObservableImpl from 'zen-observable'; @@ -25,7 +26,6 @@ import { TaskBrokerDispatchOptions, TaskCompletionState, TaskContext, - TaskSecrets, TaskStore, } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts index 2af7d6a340..f1c1a7bdac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { DatabaseTaskStore } from './DatabaseTaskStore'; export type { DatabaseTaskStoreOptions } from './DatabaseTaskStore'; export { TaskManager } from './StorageTaskBroker'; @@ -20,7 +21,6 @@ export type { CurrentClaimedTask } from './StorageTaskBroker'; export { TaskWorker } from './TaskWorker'; export type { CreateWorkerOptions } from './TaskWorker'; export type { - TaskSecrets, TaskCompletionState, TaskStoreEmitOptions, TaskStoreListEventsOptions, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 5d3f8113ee..89f6c3e213 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -16,6 +16,7 @@ import { JsonValue, JsonObject, Observable } from '@backstage/types'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; /** * The status of each step of the Task @@ -71,15 +72,6 @@ export type SerializedTaskEvent = { createdAt: string; }; -/** - * TaskSecrets - * - * @public - */ -export type TaskSecrets = Record & { - backstageToken?: string; -}; - /** * The result of {@link TaskBroker.dispatch} * diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 9ff8d43625..c339de8d10 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -43,7 +43,6 @@ import { DatabaseTaskStore, TaskBroker, TaskWorker, - TemplateAction, TemplateActionRegistry, } from '../scaffolder'; import { createDryRunner } from '../scaffolder/dryrun'; @@ -53,6 +52,7 @@ import { IdentityApi, IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; /** * RouterOptions diff --git a/plugins/scaffolder-node/.eslintrc.js b/plugins/scaffolder-node/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/scaffolder-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/scaffolder-node/README.md b/plugins/scaffolder-node/README.md new file mode 100644 index 0000000000..8b52daa785 --- /dev/null +++ b/plugins/scaffolder-node/README.md @@ -0,0 +1,3 @@ +# plugin-scaffolder-node + +Houses types and utilities for building scaffolder-related modules. diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md new file mode 100644 index 0000000000..506ea0517d --- /dev/null +++ b/plugins/scaffolder-node/api-report.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/plugin-scaffolder-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { JsonValue } from '@backstage/types'; +import { Logger } from 'winston'; +import { Schema } from 'jsonschema'; +import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import { UserEntity } from '@backstage/catalog-model'; +import { Writable } from 'stream'; + +// @public +export type ActionContext = { + logger: Logger; + logStream: Writable; + secrets?: TaskSecrets; + workspacePath: string; + input: TInput; + output(name: string, value: JsonValue): void; + createTemporaryDirectory(): Promise; + templateInfo?: TemplateInfo; + isDryRun?: boolean; + user?: { + entity?: UserEntity; + ref?: string; + }; +}; + +// @public +export const createTemplateAction: ( + templateAction: TemplateAction, +) => TemplateAction; + +// @alpha +export interface ScaffolderActionsExtensionPoint { + // (undocumented) + addActions(...actions: TemplateAction[]): void; +} + +// @alpha +export const scaffolderActionsExtensionPoint: ExtensionPoint; + +// @public +export type TaskSecrets = Record & { + backstageToken?: string; +}; + +// @public (undocumented) +export type TemplateAction = { + id: string; + description?: string; + examples?: { + description: string; + example: string; + }[]; + supportsDryRun?: boolean; + schema?: { + input?: Schema; + output?: Schema; + }; + handler: (ctx: ActionContext) => Promise; +}; +``` diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json new file mode 100644 index 0000000000..d426a33bad --- /dev/null +++ b/plugins/scaffolder-node/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/plugin-scaffolder-node", + "description": "The plugin-scaffolder-node module for @backstage/plugin-scaffolder-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "alphaTypes": "dist/index.alpha.d.ts", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build --experimental-type-build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-scaffolder-common": "workspace:^", + "@backstage/types": "workspace:^", + "jsonschema": "^1.2.6", + "winston": "^3.2.1" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "alpha", + "dist" + ] +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts similarity index 99% rename from plugins/scaffolder-backend/src/scaffolder/actions/createTemplateAction.ts rename to plugins/scaffolder-node/src/actions/createTemplateAction.ts index 8893fa0f20..24d88a1681 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -19,6 +19,7 @@ import { TemplateAction } from './types'; /** * This function is used to create new template actions to get type safety. + * * @public */ export const createTemplateAction = ( diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts new file mode 100644 index 0000000000..7fdee6d692 --- /dev/null +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { createTemplateAction } from './createTemplateAction'; +export { type ActionContext, type TemplateAction } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts similarity index 87% rename from plugins/scaffolder-backend/src/scaffolder/actions/types.ts rename to plugins/scaffolder-node/src/actions/types.ts index 0912cc038b..5a4f5796a1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -18,7 +18,7 @@ import { Logger } from 'winston'; import { Writable } from 'stream'; import { JsonValue, JsonObject } from '@backstage/types'; import { Schema } from 'jsonschema'; -import { TaskSecrets } from '../tasks'; +import { TaskSecrets } from '../tasks/types'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UserEntity } from '@backstage/catalog-model'; @@ -26,12 +26,12 @@ import { UserEntity } from '@backstage/catalog-model'; * ActionContext is passed into scaffolder actions. * @public */ -export type ActionContext = { +export type ActionContext = { logger: Logger; logStream: Writable; secrets?: TaskSecrets; workspacePath: string; - input: Input; + input: TInput; output(name: string, value: JsonValue): void; /** @@ -63,7 +63,7 @@ export type ActionContext = { }; /** @public */ -export type TemplateAction = { +export type TemplateAction = { id: string; description?: string; examples?: { description: string; example: string }[]; @@ -72,5 +72,5 @@ export type TemplateAction = { input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: (ctx: ActionContext) => Promise; }; diff --git a/plugins/scaffolder-node/src/extensions.ts b/plugins/scaffolder-node/src/extensions.ts new file mode 100644 index 0000000000..a706101043 --- /dev/null +++ b/plugins/scaffolder-node/src/extensions.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { TemplateAction } from './actions'; + +/** + * Extension point for managing scaffolder actions. + * + * @alpha + */ +export interface ScaffolderActionsExtensionPoint { + addActions(...actions: TemplateAction[]): void; +} + +/** + * Extension point for managing scaffolder actions. + * + * @alpha + */ +export const scaffolderActionsExtensionPoint = + createExtensionPoint({ + id: 'scaffolder.actions', + }); diff --git a/plugins/scaffolder/src/layouts/types.ts b/plugins/scaffolder-node/src/index.ts similarity index 59% rename from plugins/scaffolder/src/layouts/types.ts rename to plugins/scaffolder-node/src/index.ts index 94186d82bf..2a7c06234e 100644 --- a/plugins/scaffolder/src/layouts/types.ts +++ b/plugins/scaffolder-node/src/index.ts @@ -13,21 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { FormProps } from '@rjsf/core'; /** - * The field template from \@rjsf/core which is a react component that gets passed \@rjsf/core field related props. + * The scaffolder-node module for `@backstage/plugin-scaffolder-backend`. * - * @public + * @packageDocumentation */ -export type LayoutTemplate = FormProps['ObjectFieldTemplate']; -/** - * The type of layouts that is passed to the TemplateForms - * - * @public - */ -export interface LayoutOptions

{ - name: string; - component: LayoutTemplate

; -} +export * from './actions'; +export * from './tasks'; +export { + scaffolderActionsExtensionPoint, + type ScaffolderActionsExtensionPoint, +} from './extensions'; diff --git a/plugins/scaffolder-node/src/setupTests.ts b/plugins/scaffolder-node/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/scaffolder-node/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 {}; diff --git a/plugins/scaffolder-node/src/tasks/index.ts b/plugins/scaffolder-node/src/tasks/index.ts new file mode 100644 index 0000000000..0c1d0d813e --- /dev/null +++ b/plugins/scaffolder-node/src/tasks/index.ts @@ -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 TaskSecrets } from './types'; diff --git a/plugins/scaffolder-node/src/tasks/types.ts b/plugins/scaffolder-node/src/tasks/types.ts new file mode 100644 index 0000000000..c17b3cbde3 --- /dev/null +++ b/plugins/scaffolder-node/src/tasks/types.ts @@ -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. + */ + +/** + * TaskSecrets + * + * @public + */ +export type TaskSecrets = Record & { + backstageToken?: string; +}; diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index adc097775b..deead4366c 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -21,6 +21,7 @@ import { JsonValue } from '@backstage/types'; import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; import { SetStateAction } from 'react'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskStep } from '@backstage/plugin-scaffolder-common'; @@ -64,6 +65,11 @@ export function createScaffolderFieldExtension< options: FieldExtensionOptions, ): Extension>; +// @public +export function createScaffolderLayout( + options: LayoutOptions, +): Extension>; + // @public export type CustomFieldExtensionSchema = { returnValue: JSONSchema7; @@ -79,6 +85,9 @@ export type CustomFieldValidator = ( }, ) => void | Promise; +// @alpha (undocumented) +export const EmbeddableWorkflow: (props: WorkflowProps) => JSX.Element; + // @alpha export const extractSchemaFromStep: (inputStep: JsonObject) => { uiSchema: UiSchema; @@ -112,12 +121,28 @@ export type FieldExtensionOptions< schema?: CustomFieldExtensionSchema; }; -// @alpha +// @public export type FormProps = Pick< FormProps_2, 'transformErrors' | 'noHtml5Validate' >; +// @public +export type LayoutComponent<_TInputProps> = () => null; + +// @public +export interface LayoutOptions

{ + // (undocumented) + component: LayoutTemplate

; + // (undocumented) + name: string; +} + +// @public +export type LayoutTemplate = NonNullable< + FormProps_2['uiSchema'] +>['ui:ObjectFieldTemplate']; + // @public export type ListActionsResponse = Array; @@ -272,6 +297,9 @@ export interface ScaffolderGetIntegrationsListResponse { }[]; } +// @public +export const ScaffolderLayouts: React.ComponentType; + // @public (undocumented) export type ScaffolderOutputLink = { title?: string; @@ -342,7 +370,7 @@ export const SecretsContextProvider: ({ }: PropsWithChildren<{}>) => JSX.Element; // @alpha -export const Stepper: (props: StepperProps) => JSX.Element; +export const Stepper: (stepperProps: StepperProps) => JSX.Element; // @alpha export type StepperProps = { @@ -351,7 +379,13 @@ export type StepperProps = { templateName?: string; FormProps?: FormProps; initialState?: Record; - onComplete: (values: Record) => Promise; + onCreate: (values: Record) => Promise; + components?: { + ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element; + createButtonText?: ReactNode; + reviewButtonText?: ReactNode; + }; + layouts?: LayoutOptions[]; }; // @alpha @@ -413,11 +447,23 @@ export const useCustomFieldExtensions: < outlet: React.ReactNode, ) => TComponentDataType[]; +// @public +export const useCustomLayouts: >( + outlet: React.ReactNode, +) => TComponentDataType[]; + // @alpha export const useFormDataFromQuery: ( initialState?: Record, ) => [Record, Dispatch>>]; +// @alpha (undocumented) +export const useTemplateParameterSchema: (templateRef: string) => { + manifest: TemplateParameterSchema | undefined; + loading: boolean; + error: Error | undefined; +}; + // @alpha export const useTemplateSchema: (manifest: TemplateParameterSchema) => { steps: ParsedTemplateSchema[]; @@ -426,5 +472,25 @@ export const useTemplateSchema: (manifest: TemplateParameterSchema) => { // @public export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets; +// @alpha (undocumented) +export const Workflow: (workflowProps: WorkflowProps) => JSX.Element | null; + +// @alpha (undocumented) +export type WorkflowProps = { + title?: string; + description?: string; + namespace: string; + templateName: string; + onError(error: Error | undefined): JSX.Element | null; +} & Pick< + StepperProps, + | 'extensions' + | 'FormProps' + | 'components' + | 'onCreate' + | 'initialState' + | 'layouts' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 229d0dfd5c..93ffd18d51 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -58,6 +58,7 @@ "json-schema-library": "^7.3.9", "lodash": "^4.17.21", "qs": "^6.9.4", + "react-use": "^17.2.4", "zen-observable": "^0.10.0", "zod": "~3.18.0", "zod-to-json-schema": "~3.18.0" diff --git a/plugins/scaffolder-react/src/extensions/index.tsx b/plugins/scaffolder-react/src/extensions/index.tsx index 33442f6697..d70f5e36c5 100644 --- a/plugins/scaffolder-react/src/extensions/index.tsx +++ b/plugins/scaffolder-react/src/extensions/index.tsx @@ -25,7 +25,8 @@ import { Extension, attachComponentData } from '@backstage/core-plugin-api'; import { FIELD_EXTENSION_KEY, FIELD_EXTENSION_WRAPPER_KEY } from './keys'; /** - * A type used to wrap up the FieldExtension to embed the ReturnValue and the InputProps + * The type used to wrap up the Layout and embed the input props + * * @public */ export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null; diff --git a/plugins/scaffolder-react/src/hooks/index.ts b/plugins/scaffolder-react/src/hooks/index.ts index 022fe7c38c..af64a9eebc 100644 --- a/plugins/scaffolder-react/src/hooks/index.ts +++ b/plugins/scaffolder-react/src/hooks/index.ts @@ -15,3 +15,4 @@ */ export { useCustomFieldExtensions } from './useCustomFieldExtensions'; +export { useCustomLayouts } from './useCustomLayouts'; diff --git a/plugins/scaffolder-react/src/hooks/useCustomLayouts.ts b/plugins/scaffolder-react/src/hooks/useCustomLayouts.ts new file mode 100644 index 0000000000..afcc45cd48 --- /dev/null +++ b/plugins/scaffolder-react/src/hooks/useCustomLayouts.ts @@ -0,0 +1,36 @@ +/* + * 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 { useElementFilter } from '@backstage/core-plugin-api'; +import { LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../next/layouts/keys'; +import { type LayoutOptions } from '../next/types'; + +/** + * Hook that returns all custom field extensions from the current outlet. + * @public + */ +export const useCustomLayouts = ( + outlet: React.ReactNode, +) => { + return useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: LAYOUTS_WRAPPER_KEY, + }) + .findComponentData({ + key: LAYOUTS_KEY, + }), + ); +}; diff --git a/plugins/scaffolder-react/src/index.ts b/plugins/scaffolder-react/src/index.ts index 0b053e6bf4..41a1298b37 100644 --- a/plugins/scaffolder-react/src/index.ts +++ b/plugins/scaffolder-react/src/index.ts @@ -21,3 +21,4 @@ export * from './api'; export * from './hooks'; export * from './next'; +export type { FormProps } from './next'; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index 786f45b05d..5995d0e419 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -21,6 +21,7 @@ import { act, fireEvent } from '@testing-library/react'; import type { RJSFValidationError } from '@rjsf/utils'; import { JsonValue } from '@backstage/types'; import { NextFieldExtensionComponentProps } from '../../extensions'; +import { LayoutTemplate } from '../../types'; describe('Stepper', () => { it('should render the step titles for each step of the manifest', async () => { @@ -33,7 +34,7 @@ describe('Stepper', () => { }; const { getByText } = await renderInTestApp( - , + , ); for (const step of manifest.steps) { @@ -51,7 +52,7 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + , ); expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); @@ -91,7 +92,7 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + , ); await fireEvent.change(getByRole('textbox', { name: 'name' }), { @@ -162,7 +163,7 @@ describe('Stepper', () => { title: 'React JSON Schema Form Test', }; - const onComplete = jest.fn(async (values: Record) => { + const onCreate = jest.fn(async (values: Record) => { expect(values).toEqual({ first: { repository: 'Repo' }, second: { owner: 'Owner' }, @@ -172,7 +173,7 @@ describe('Stepper', () => { const { getByRole } = await renderInTestApp( { await fireEvent.click(getByRole('button', { name: 'Create' })); }); - expect(onComplete).toHaveBeenCalled(); + expect(onCreate).toHaveBeenCalled(); }); it('should render custom field extensions properly', async () => { @@ -229,7 +230,7 @@ describe('Stepper', () => { , ); @@ -266,7 +267,7 @@ describe('Stepper', () => { , ); @@ -308,7 +309,7 @@ describe('Stepper', () => { }); const { getByRole } = await renderInTestApp( - , + , ); expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John'); @@ -331,12 +332,12 @@ describe('Stepper', () => { title: 'initialize formData', }; - const onComplete = jest.fn(async (values: Record) => { + const onCreate = jest.fn(async (values: Record) => { expect(values).toHaveProperty('firstName'); }); const { getByRole } = await renderInTestApp( - , + , ); await act(async () => { @@ -352,4 +353,87 @@ describe('Stepper', () => { // flush promises return new Promise(process.nextTick); }); + + it('should override the Create and Review button text', async () => { + const manifest: TemplateParameterSchema = { + title: 'Custom Fields', + steps: [ + { + title: 'Test', + schema: { + properties: { + name: { + type: 'string', + }, + }, + }, + }, + ], + }; + + const { getByRole } = await renderInTestApp( + Make, + reviewButtonText: Inspect, + }} + />, + ); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Inspect' })); + }); + + expect(getByRole('button', { name: 'Make' })).toBeInTheDocument(); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Make' })); + }); + }); + + describe('Scaffolder Layouts', () => { + it('should render the step in the scaffolder layout', async () => { + const ScaffolderLayout: LayoutTemplate = ({ properties }) => ( + <> +

A Scaffolder Layout

+ {properties.map((prop, i) => ( +
{prop.content}
+ ))} + + ); + + const manifest: TemplateParameterSchema = { + steps: [ + { + title: 'Step 1', + schema: { + type: 'object', + 'ui:ObjectFieldTemplate': 'Layout', + properties: { + field1: { + type: 'string', + }, + }, + }, + }, + ], + title: 'scaffolder layouts', + }; + + const { getByText, getByRole } = await renderInTestApp( + , + ); + + expect(getByText('A Scaffolder Layout')).toBeInTheDocument(); + expect(getByRole('textbox', { name: 'field1' })).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index cde299da7e..6189bdc975 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -24,16 +24,16 @@ import { } from '@material-ui/core'; import { type IChangeEvent, withTheme } from '@rjsf/core-v5'; import { ErrorSchema, FieldValidation } from '@rjsf/utils'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; import { TemplateParameterSchema } from '../../../types'; import { createAsyncValidators } from './createAsyncValidators'; +import { ReviewState, type ReviewStateProps } from '../ReviewState'; import { useTemplateSchema } from '../../hooks/useTemplateSchema'; -import { ReviewState } from '../ReviewState'; import validator from '@rjsf/validator-ajv6'; - import { useFormDataFromQuery } from '../../hooks'; -import { FormProps } from '../../types'; +import type { FormProps, LayoutOptions } from '../../types'; +import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; const useStyles = makeStyles(theme => ({ backButton: { @@ -60,8 +60,13 @@ export type StepperProps = { templateName?: string; FormProps?: FormProps; initialState?: Record; - - onComplete: (values: Record) => Promise; + onCreate: (values: Record) => Promise; + components?: { + ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element; + createButtonText?: ReactNode; + reviewButtonText?: ReactNode; + }; + layouts?: LayoutOptions[]; }; // TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly @@ -73,7 +78,13 @@ const Form = withTheme(require('@rjsf/material-ui-v5').Theme); * The `Stepper` component is the Wizard that is rendered when a user selects a template * @alpha */ -export const Stepper = (props: StepperProps) => { +export const Stepper = (stepperProps: StepperProps) => { + const { layouts = [], components = {}, ...props } = stepperProps; + const { + ReviewStateComponent = ReviewState, + createButtonText = 'Create', + reviewButtonText = 'Review', + } = components; const analytics = useAnalytics(); const { steps } = useTemplateSchema(props.manifest); const apiHolder = useApiHolder(); @@ -141,6 +152,8 @@ export const Stepper = (props: StepperProps) => { setFormState(current => ({ ...current, ...formData })); }; + const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts }); + return ( <> @@ -160,8 +173,8 @@ export const Stepper = (props: StepperProps) => { extraErrors={errors as unknown as ErrorSchema} formData={formState} formContext={{ formData: formState }} - schema={steps[activeStep].schema} - uiSchema={steps[activeStep].uiSchema} + schema={currentStep.schema} + uiSchema={currentStep.uiSchema} onSubmit={handleNext} fields={extensions} showErrorList={false} @@ -177,13 +190,13 @@ export const Stepper = (props: StepperProps) => { Back ) : ( <> - +
diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx new file mode 100644 index 0000000000..03604881d2 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider } from '@backstage/core-app-api'; +import { + MockAnalyticsApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; +import { act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { Workflow } from './Workflow'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { ScaffolderApi } from '../../../api/types'; +import { scaffolderApiRef } from '../../../api/ref'; + +const scaffolderApiMock: jest.Mocked = { + scaffold: jest.fn(), + getTemplateParameterSchema: jest.fn(), + getIntegrationsList: jest.fn(), + getTask: jest.fn(), + streamLogs: jest.fn(), + listActions: jest.fn(), + listTasks: jest.fn(), +}; + +const analyticsMock = new MockAnalyticsApi(); +const apis = TestApiRegistry.from( + [scaffolderApiRef, scaffolderApiMock], + [analyticsApiRef, analyticsMock], +); + +describe('', () => { + it('should complete a workflow', async () => { + const onCreate = jest.fn(); + const onError = jest.fn(); + scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' }); + + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({ + steps: [ + { + title: 'Step 1', + schema: { + properties: { + name: { + type: 'string', + }, + }, + }, + }, + { + title: 'Step 2', + schema: { + properties: { + age: { + type: 'string', + }, + }, + }, + }, + ], + title: 'React JSON Schema Form Test', + }); + + const { getByRole, getAllByRole, getByText } = await renderInTestApp( + + ( +

This is a different wrapper for the review page

+ ), + reviewButtonText: Onwards, + createButtonText: Make, + }} + extensions={[]} + /> +
, + ); + + // Test template title is overriden + expect(getByRole('heading', { level: 2 }).innerHTML).toBe( + 'Different title than template', + ); + + const nameInput = getByRole('textbox', { + name: 'name', + }) as HTMLInputElement; + + expect(nameInput).toBeInTheDocument(); + + expect(nameInput.value).toBe('prefilled-name'); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Next' })); + }); + + const ageInput = getByRole('textbox', { name: 'age' }) as HTMLInputElement; + + expect(ageInput.value).toBe('53'); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Onwards' })); + }); + + expect( + getByText('This is a different wrapper for the review page'), + ).toBeDefined(); + + await act(async () => { + fireEvent.click(getAllByRole('button')[1] as HTMLButtonElement); + }); + + expect(onCreate).toHaveBeenCalledWith({ + name: 'prefilled-name', + age: '53', + }); + }); +}); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx new file mode 100644 index 0000000000..f6ff12e3a3 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -0,0 +1,119 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useEffect } from 'react'; +import { + Content, + InfoCard, + MarkdownContent, + Progress, +} from '@backstage/core-components'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; +import { Stepper, type StepperProps } from '../Stepper/Stepper'; +import { SecretsContextProvider } from '../../../secrets/SecretsContext'; + +const useStyles = makeStyles(() => ({ + markdown: { + /** to make the styles for React Markdown not leak into the description */ + '& :first-child': { + marginTop: 0, + }, + '& :last-child': { + marginBottom: 0, + }, + }, +})); + +/** + * @alpha + */ +export type WorkflowProps = { + title?: string; + description?: string; + namespace: string; + templateName: string; + onError(error: Error | undefined): JSX.Element | null; +} & Pick< + StepperProps, + | 'extensions' + | 'FormProps' + | 'components' + | 'onCreate' + | 'initialState' + | 'layouts' +>; + +/** + * @alpha + */ +export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { + const { title, description, namespace, templateName, ...props } = + workflowProps; + + const styles = useStyles(); + const templateRef = stringifyEntityRef({ + kind: 'Template', + namespace: namespace, + name: templateName, + }); + + const errorApi = useApi(errorApiRef); + + const { loading, manifest, error } = useTemplateParameterSchema(templateRef); + + useEffect(() => { + if (error) { + errorApi.post(new Error(`Failed to load template, ${error}`)); + } + }, [error, errorApi]); + + if (error) { + return props.onError(error); + } + + return ( + + {loading && } + {manifest && ( + + } + noPadding + titleTypographyProps={{ component: 'h2' }} + > + + + )} + + ); +}; + +/** + * @alpha + */ +export const EmbeddableWorkflow = (props: WorkflowProps) => ( + + + +); diff --git a/packages/backend-test-utils/src/next/implementations/mockConfigService.ts b/plugins/scaffolder-react/src/next/components/Workflow/index.ts similarity index 57% rename from packages/backend-test-utils/src/next/implementations/mockConfigService.ts rename to plugins/scaffolder-react/src/next/components/Workflow/index.ts index 1d04faad91..193a6417fc 100644 --- a/packages/backend-test-utils/src/next/implementations/mockConfigService.ts +++ b/plugins/scaffolder-react/src/next/components/Workflow/index.ts @@ -13,21 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { - coreServices, - createServiceFactory, -} from '@backstage/backend-plugin-api'; -import { ConfigReader } from '@backstage/config'; -import { JsonObject } from '@backstage/types'; - -/** @alpha */ -export const mockConfigFactory = createServiceFactory( - (options?: { data?: JsonObject }) => ({ - service: coreServices.config, - deps: {}, - async factory() { - return new ConfigReader(options?.data, 'mock-config'); - }, - }), -); +export { Workflow, EmbeddableWorkflow, type WorkflowProps } from './Workflow'; diff --git a/plugins/scaffolder-react/src/next/components/index.ts b/plugins/scaffolder-react/src/next/components/index.ts index c710bd8891..babe3b4dcb 100644 --- a/plugins/scaffolder-react/src/next/components/index.ts +++ b/plugins/scaffolder-react/src/next/components/index.ts @@ -17,3 +17,4 @@ export * from './Stepper'; export * from './TemplateCard'; export * from './ReviewState'; export * from './TemplateGroup'; +export * from './Workflow'; diff --git a/plugins/scaffolder-react/src/next/hooks/index.ts b/plugins/scaffolder-react/src/next/hooks/index.ts index 78a4d2a777..ca8ce10eff 100644 --- a/plugins/scaffolder-react/src/next/hooks/index.ts +++ b/plugins/scaffolder-react/src/next/hooks/index.ts @@ -18,3 +18,4 @@ export { useTemplateSchema, type ParsedTemplateSchema, } from './useTemplateSchema'; +export { useTemplateParameterSchema } from './useTemplateParameterSchema'; diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts new file mode 100644 index 0000000000..36b8b1e0bc --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts @@ -0,0 +1,32 @@ +import { useApi } from '@backstage/core-plugin-api'; + +/* + * 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 useAsync from 'react-use/lib/useAsync'; +import { scaffolderApiRef } from '../../api/ref'; + +/** + * @alpha + */ +export const useTemplateParameterSchema = (templateRef: string) => { + const scaffolderApi = useApi(scaffolderApiRef); + const { value, loading, error } = useAsync( + () => scaffolderApi.getTemplateParameterSchema(templateRef), + [scaffolderApi, templateRef], + ); + + return { manifest: value, loading, error }; +}; diff --git a/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.test.tsx new file mode 100644 index 0000000000..6dba7b9f29 --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { TestApiProvider } from '@backstage/test-utils'; +import { type ParsedTemplateSchema } from './useTemplateSchema'; +import { useTransformSchemaToProps } from './useTransformSchemaToProps'; + +describe('useTransformSchemaToProps', () => { + it('should replace ui:ObjectFieldTemplate with actual component', () => { + const layouts = [{ name: 'TwoColumn', component: jest.fn() }]; + + const step: ParsedTemplateSchema = { + title: 'Fill in some steps', + mergedSchema: {}, + schema: {}, + uiSchema: { + 'ui:ObjectFieldTemplate': 'TwoColumn' as any, + name: { + 'ui:field': 'EntityNamePicker', + 'ui:autofocus': true, + }, + }, + }; + + const { result } = renderHook( + () => useTransformSchemaToProps(step, { layouts }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + + const { uiSchema } = result.current; + + expect(uiSchema['ui:ObjectFieldTemplate']).toEqual(layouts[0].component); + }); +}); diff --git a/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.ts b/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.ts new file mode 100644 index 0000000000..ffae1df8bf --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTransformSchemaToProps.ts @@ -0,0 +1,51 @@ +/* + * 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 { type ParsedTemplateSchema } from './useTemplateSchema'; +import { type LayoutOptions } from '../types'; + +interface Options { + layouts?: LayoutOptions[]; +} + +export const useTransformSchemaToProps = ( + step: ParsedTemplateSchema, + options: Options = {}, +): ParsedTemplateSchema => { + const { layouts = [] } = options; + const objectFieldTemplate = step?.uiSchema['ui:ObjectFieldTemplate'] as + | string + | undefined; + + if (typeof objectFieldTemplate !== 'string') { + return step; + } + + const Layout = layouts.find( + layout => layout.name === objectFieldTemplate, + )?.component; + + if (!Layout) { + return step; + } + + return { + ...step, + uiSchema: { + ...step.uiSchema, + ['ui:ObjectFieldTemplate']: Layout, + }, + }; +}; diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts index a18649f506..4674c1f70a 100644 --- a/plugins/scaffolder-react/src/next/index.ts +++ b/plugins/scaffolder-react/src/next/index.ts @@ -15,6 +15,8 @@ */ export * from './components'; export * from './extensions'; +export * from './layouts'; export * from './types'; export * from './lib'; export * from './hooks'; +export * from './layouts'; diff --git a/plugins/scaffolder/src/layouts/index.tsx b/plugins/scaffolder-react/src/next/layouts/index.tsx similarity index 80% rename from plugins/scaffolder/src/layouts/index.tsx rename to plugins/scaffolder-react/src/next/layouts/index.tsx index ce1dac9bdf..0b1533c7c8 100644 --- a/plugins/scaffolder/src/layouts/index.tsx +++ b/plugins/scaffolder-react/src/next/layouts/index.tsx @@ -1,5 +1,8 @@ +import { LayoutOptions } from '../types'; +import { LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from './keys'; + /* - * Copyright 2022 The Backstage Authors + * 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. @@ -13,16 +16,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { attachComponentData, Extension } from '@backstage/core-plugin-api'; -import type { LayoutOptions } from './types'; - -export const LAYOUTS_KEY = 'scaffolder.layout.v1'; -export const LAYOUTS_WRAPPER_KEY = 'scaffolder.layouts.wrapper.v1'; /** - * The type used to wrap up the Layout and embed the input props - * + * A type used to wrap up the FieldExtension to embed the ReturnValue and the InputProps * @public */ export type LayoutComponent<_TInputProps> = () => null; @@ -55,5 +52,3 @@ export const ScaffolderLayouts: React.ComponentType = (): JSX.Element | null => null; attachComponentData(ScaffolderLayouts, LAYOUTS_WRAPPER_KEY, true); - -export type { LayoutOptions, LayoutTemplate } from './types'; diff --git a/plugins/scaffolder-react/src/next/layouts/keys.ts b/plugins/scaffolder-react/src/next/layouts/keys.ts new file mode 100644 index 0000000000..a82b1597f4 --- /dev/null +++ b/plugins/scaffolder-react/src/next/layouts/keys.ts @@ -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 const LAYOUTS_KEY = 'scaffolder.layout.v1'; +export const LAYOUTS_WRAPPER_KEY = 'scaffolder.layouts.wrapper.v1'; diff --git a/plugins/scaffolder-react/src/next/types.ts b/plugins/scaffolder-react/src/next/types.ts index 32d0fe85f0..40ba511dbe 100644 --- a/plugins/scaffolder-react/src/next/types.ts +++ b/plugins/scaffolder-react/src/next/types.ts @@ -14,13 +14,31 @@ * limitations under the License. */ import type { FormProps as SchemaFormProps } from '@rjsf/core-v5'; - /** * Any `@rjsf/core` form properties that are publicly exposed to the `NextScaffolderpage` * - * @alpha + * @public */ export type FormProps = Pick< SchemaFormProps, 'transformErrors' | 'noHtml5Validate' >; + +/** + * The field template from `@rjsf/core` which is a react component that gets passed `@rjsf/core` field related props. + * + * @public + */ +export type LayoutTemplate = NonNullable< + SchemaFormProps['uiSchema'] +>['ui:ObjectFieldTemplate']; + +/** + * The type of layouts that is passed to the TemplateForms + * + * @public + */ +export interface LayoutOptions

{ + name: string; + component: LayoutTemplate

; +} diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 82a8dfd8d3..1fb72b0fac 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -10,21 +10,23 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { createScaffolderFieldExtension as createScaffolderFieldExtension_2 } from '@backstage/plugin-scaffolder-react'; +import { createScaffolderLayout as createScaffolderLayout_2 } from '@backstage/plugin-scaffolder-react'; import { CustomFieldExtensionSchema as CustomFieldExtensionSchema_2 } from '@backstage/plugin-scaffolder-react'; import { CustomFieldValidator as CustomFieldValidator_2 } from '@backstage/plugin-scaffolder-react'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { Extension } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; import { FieldExtensionComponent as FieldExtensionComponent_2 } from '@backstage/plugin-scaffolder-react'; import { FieldExtensionComponentProps as FieldExtensionComponentProps_2 } from '@backstage/plugin-scaffolder-react'; import { FieldExtensionOptions as FieldExtensionOptions_2 } from '@backstage/plugin-scaffolder-react'; import { FieldValidation } from '@rjsf/core'; -import type { FormProps as FormProps_2 } from '@rjsf/core'; +import { FormProps as FormProps_2 } from '@backstage/plugin-scaffolder-react'; import type { FormProps as FormProps_3 } from '@rjsf/core-v5'; import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; +import { LayoutOptions as LayoutOptions_2 } from '@backstage/plugin-scaffolder-react'; +import { LayoutTemplate as LayoutTemplate_2 } from '@backstage/plugin-scaffolder-react'; import { ListActionsResponse as ListActionsResponse_2 } from '@backstage/plugin-scaffolder-react'; import { LogEvent as LogEvent_2 } from '@backstage/plugin-scaffolder-react'; import { Observable } from '@backstage/types'; @@ -56,10 +58,8 @@ import { z } from 'zod'; // @public @deprecated (undocumented) export const createScaffolderFieldExtension: typeof createScaffolderFieldExtension_2; -// @public -export function createScaffolderLayout( - options: LayoutOptions, -): Extension>; +// @public @deprecated (undocumented) +export const createScaffolderLayout: typeof createScaffolderLayout_2; // @public @deprecated (undocumented) export type CustomFieldExtensionSchema = CustomFieldExtensionSchema_2; @@ -161,19 +161,11 @@ export type FormProps = Pick< 'transformErrors' | 'noHtml5Validate' >; -// @public -export type LayoutComponent<_TInputProps> = () => null; +// @public @deprecated (undocumented) +export type LayoutOptions = LayoutOptions_2; -// @public -export interface LayoutOptions

{ - // (undocumented) - component: LayoutTemplate

; - // (undocumented) - name: string; -} - -// @public -export type LayoutTemplate = FormProps_2['ObjectFieldTemplate']; +// @public @deprecated (undocumented) +export type LayoutTemplate = LayoutTemplate_2; // @public @deprecated (undocumented) export type ListActionsResponse = ListActionsResponse_2; @@ -207,7 +199,7 @@ export type NextRouterProps = { TaskPageComponent?: React_2.ComponentType<{}>; }; groups?: TemplateGroupFilter[]; - FormProps?: FormProps; + FormProps?: FormProps_2; }; // @alpha @@ -453,8 +445,8 @@ export type ScaffolderGetIntegrationsListOptions = export type ScaffolderGetIntegrationsListResponse = ScaffolderGetIntegrationsListResponse_2; -// @public -export const ScaffolderLayouts: React.ComponentType; +// @public @deprecated (undocumented) +export const ScaffolderLayouts: ComponentType<{}>; // @public @deprecated (undocumented) export type ScaffolderOutputlink = ScaffolderOutputLink; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 3a1196bc0e..a74b65b885 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -35,10 +35,12 @@ import React, { ComponentType, useState } from 'react'; import { transformSchemaToProps } from './schema'; import cloneDeep from 'lodash/cloneDeep'; import * as fieldOverrides from './FieldOverrides'; -import { LayoutOptions } from '../../layouts'; import { ReviewStepProps } from '../types'; import { ReviewStep } from './ReviewStep'; -import { extractSchemaFromStep } from '@backstage/plugin-scaffolder-react'; +import { + extractSchemaFromStep, + type LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; import { selectedTemplateRouteRef } from '../../routes'; const Form = withTheme(MuiTheme); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts index 5d74b0fc81..c3c90ae86a 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts @@ -16,7 +16,7 @@ import { JsonObject } from '@backstage/types'; import { FormProps, UiSchema } from '@rjsf/core'; -import { LayoutOptions } from '../../layouts'; +import type { LayoutOptions } from '@backstage/plugin-scaffolder-react'; function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -120,7 +120,8 @@ export function transformSchemaToProps( )?.component; if (Layout) { - uiSchema['ui:ObjectFieldTemplate'] = Layout; + uiSchema['ui:ObjectFieldTemplate'] = + Layout as unknown as FormProps['ObjectFieldTemplate']; } } diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 6a2da406e0..ccc2bd283b 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -24,18 +24,14 @@ import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; import { TemplateEditorPage } from './TemplateEditorPage'; import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../extensions/default'; -import { - useElementFilter, - useRouteRef, - useRouteRefParams, -} from '@backstage/core-plugin-api'; +import { useRouteRef, useRouteRefParams } from '@backstage/core-plugin-api'; import { FieldExtensionOptions, SecretsContextProvider, useCustomFieldExtensions, + useCustomLayouts, } from '@backstage/plugin-scaffolder-react'; import { ListTasksPage } from './ListTasksPage'; -import { LayoutOptions, LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../layouts'; import { ReviewStepProps } from './types'; import { actionsRouteRef, @@ -104,16 +100,7 @@ export const Router = (props: RouterProps) => { ), ] as FieldExtensionOptions[]; - // todo(blam): this should also be moved to a hook in -react - const customLayouts = useElementFilter(outlet, elements => - elements - .selectByComponentData({ - key: LAYOUTS_WRAPPER_KEY, - }) - .findComponentData({ - key: LAYOUTS_KEY, - }), - ); + const customLayouts = useCustomLayouts(outlet); /** * This component can be deleted once the older routes have been deprecated. diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index dc67f687bb..e07434aff7 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -15,8 +15,10 @@ */ import { makeStyles } from '@material-ui/core'; import React, { useState } from 'react'; -import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; -import type { LayoutOptions } from '../../layouts'; +import type { + FieldExtensionOptions, + LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; import { TemplateDirectoryAccess } from '../../lib/filesystem'; import { DirectoryEditorProvider } from './DirectoryEditorContext'; import { DryRunProvider } from './DryRunContext'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx index c62112a89c..315028219b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx @@ -19,11 +19,11 @@ import { makeStyles } from '@material-ui/core/styles'; import React, { Component, ReactNode, useMemo, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import yaml from 'yaml'; -import { +import type { FieldExtensionOptions, + LayoutOptions, TemplateParameterSchema, } from '@backstage/plugin-scaffolder-react'; -import { LayoutOptions } from '../../layouts'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { createValidator } from '../TemplatePage'; import { useDirectoryEditor } from './DirectoryEditorContext'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 20691a8e85..ebb763e145 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -23,8 +23,10 @@ import { CustomFieldExplorer } from './CustomFieldExplorer'; import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; -import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; -import type { LayoutOptions } from '../../layouts'; +import { + type FieldExtensionOptions, + type LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; type Selection = | { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index 884305bf64..a7e3c6a697 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -32,8 +32,10 @@ import CloseIcon from '@material-ui/icons/Close'; import React, { useCallback, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import yaml from 'yaml'; -import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; -import { LayoutOptions } from '../../layouts'; +import { + type FieldExtensionOptions, + type LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; import { TemplateEditorForm } from './TemplateEditorForm'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0f484ff404..c615649f93 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -20,7 +20,8 @@ import React, { ComponentType, useCallback, useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; import { - FieldExtensionOptions, + type FieldExtensionOptions, + type LayoutOptions, scaffolderApiRef, useTemplateSecrets, } from '@backstage/plugin-scaffolder-react'; @@ -37,7 +38,6 @@ import { useRouteRefParams, } from '@backstage/core-plugin-api'; import { stringifyEntityRef } from '@backstage/catalog-model'; -import { LayoutOptions } from '../../layouts'; import { ReviewStepProps } from '../types'; import { rootRouteRef, diff --git a/plugins/scaffolder/src/deprecated.ts b/plugins/scaffolder/src/deprecated.ts index 201f7ac7d7..f08a83c594 100644 --- a/plugins/scaffolder/src/deprecated.ts +++ b/plugins/scaffolder/src/deprecated.ts @@ -25,6 +25,10 @@ import { ScaffolderFieldExtensions as ScaffolderFieldExtensionsTemp, useTemplateSecrets as useTemplateSecretsTemp, scaffolderApiRef as scaffolderApiRefTemp, + createScaffolderLayout as createScaffolderLayoutTemp, + ScaffolderLayouts as ScaffolderLayoutsTemp, + type LayoutOptions as LayoutOptionsTemp, + type LayoutTemplate as LayoutTemplateTemp, type ScaffolderApi as ScaffolderApiTemp, type ScaffolderUseTemplateSecrets as ScaffolderUseTemplateSecretsTemp, type TemplateParameterSchema as TemplateParameterSchemaTemp, @@ -188,3 +192,23 @@ export type ScaffolderTaskOutput = ScaffolderTaskOutputTemp; * @deprecated use import from {@link @backstage/plugin-scaffolder-react#ScaffolderTaskStatus} instead as this has now been moved. */ export type ScaffolderTaskStatus = ScaffolderTaskStatusTemp; +/** + * @public + * @deprecated use import from {@link @backstage/plugin-scaffolder-react#createScaffolderLayout} instead as this has now been moved. + */ +export const createScaffolderLayout = createScaffolderLayoutTemp; +/** + * @public + * @deprecated use import from {@link @backstage/plugin-scaffolder-react#ScaffolderLayouts} instead as this has now been moved. + */ +export const ScaffolderLayouts = ScaffolderLayoutsTemp; +/** + * @public + * @deprecated use import from {@link @backstage/plugin-scaffolder-react#LayoutTemplate} instead as this has now been moved. + */ +export type LayoutTemplate = LayoutTemplateTemp; +/** + * @public + * @deprecated use import from {@link @backstage/plugin-scaffolder-react#LayoutOptions} instead as this has now been moved. + */ +export type LayoutOptions = LayoutOptionsTemp; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 0e2b293982..aa78874f9d 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -21,14 +21,6 @@ */ export { ScaffolderClient } from './api'; -export { - createScaffolderLayout, - ScaffolderLayouts, - type LayoutOptions, - type LayoutTemplate, - type LayoutComponent, -} from './layouts'; - export { EntityPickerFieldExtension, EntityNamePickerFieldExtension, diff --git a/plugins/scaffolder/src/next/Router/Router.test.tsx b/plugins/scaffolder/src/next/Router/Router.test.tsx index 4e5535be6c..9565572a2f 100644 --- a/plugins/scaffolder/src/next/Router/Router.test.tsx +++ b/plugins/scaffolder/src/next/Router/Router.test.tsx @@ -23,6 +23,10 @@ import { ScaffolderFieldExtensions, } from '@backstage/plugin-scaffolder-react'; import { scaffolderPlugin } from '../../plugin'; +import { + createScaffolderLayout, + ScaffolderLayouts, +} from '@backstage/plugin-scaffolder-react'; jest.mock('../TemplateListPage', () => ({ TemplateListPage: jest.fn(() => null), @@ -106,5 +110,36 @@ describe('Router', () => { ]), ); }); + + it('should extract the layouts and pass them through', async () => { + const mockLayoutComponent = () => null; + const Layout = scaffolderPlugin.provide( + createScaffolderLayout({ + name: 'layout', + component: mockLayoutComponent, + }), + ); + + await renderInTestApp( + + + + + , + { routeEntries: ['/templates/default/foo'] }, + ); + + const mock = TemplateWizardPage as jest.Mock; + const [{ layouts }] = mock.mock.calls[0]; + + expect(layouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'layout', + component: mockLayoutComponent, + }), + ]), + ); + }); }); }); diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx index 784e17dd55..8dd47b478e 100644 --- a/plugins/scaffolder/src/next/Router/Router.tsx +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -21,12 +21,13 @@ import { NextFieldExtensionOptions, SecretsContextProvider, useCustomFieldExtensions, + useCustomLayouts, + type FormProps, } from '@backstage/plugin-scaffolder-react'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups'; import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default'; -import { FormProps } from '../types'; import { nextSelectedTemplateRouteRef } from '../routes'; /** @@ -65,6 +66,8 @@ export const Router = (props: PropsWithChildren) => { ), ] as NextFieldExtensionOptions[]; + const customLayouts = useCustomLayouts(outlet); + return ( ) => { diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx index c4e8a51e1f..d24482a583 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -13,20 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; -import { - Page, - Header, - Content, - Progress, - InfoCard, - MarkdownContent, -} from '@backstage/core-components'; +import React from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { AnalyticsContext, - errorApiRef, useApi, useRouteRef, useRouteRefParams, @@ -34,48 +25,23 @@ import { import { scaffolderApiRef, useTemplateSecrets, + Workflow, + type LayoutOptions, } from '@backstage/plugin-scaffolder-react'; -import useAsync from 'react-use/lib/useAsync'; -import { makeStyles } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { - Stepper, - NextFieldExtensionOptions, -} from '@backstage/plugin-scaffolder-react'; +import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { JsonValue } from '@backstage/types'; -import { FormProps } from '../types'; +import { type FormProps } from '../types'; import { nextRouteRef } from '../routes'; import { scaffolderTaskRouteRef, selectedTemplateRouteRef } from '../../routes'; +import { Header, Page } from '@backstage/core-components'; -type TemplateWizardPageProps = { +export type TemplateWizardPageProps = { customFieldExtensions: NextFieldExtensionOptions[]; + layouts?: LayoutOptions[]; FormProps?: FormProps; }; -const useStyles = makeStyles(() => ({ - markdown: { - /** to make the styles for React Markdown not leak into the description */ - '& :first-child': { - marginTop: 0, - }, - '& :last-child': { - marginBottom: 0, - }, - }, -})); - -const useTemplateParameterSchema = (templateRef: string) => { - const scaffolderApi = useApi(scaffolderApiRef); - const { value, loading, error } = useAsync( - () => scaffolderApi.getTemplateParameterSchema(templateRef), - [scaffolderApi, templateRef], - ); - - return { manifest: value, loading, error }; -}; - export const TemplateWizardPage = (props: TemplateWizardPageProps) => { - const styles = useStyles(); const rootRef = useRouteRef(nextRouteRef); const taskRoute = useRouteRef(scaffolderTaskRouteRef); const { secrets } = useTemplateSecrets(); @@ -91,10 +57,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { name: templateName, }); - const errorApi = useApi(errorApiRef); - const { loading, manifest, error } = useTemplateParameterSchema(templateRef); - - const onComplete = async (values: Record) => { + const onCreate = async (values: Record) => { const { taskId } = await scaffolderApi.scaffold({ templateRef, values, @@ -104,15 +67,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { navigate(taskRoute({ taskId })); }; - useEffect(() => { - if (error) { - errorApi.post(new Error(`Failed to load template, ${error}`)); - } - }, [error, errorApi]); - - if (error) { - return ; - } + const onError = () => ; return ( @@ -122,29 +77,15 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { title="Create a new component" subtitle="Create new software components using standard templates in your organization" /> - - {loading && } - {manifest && ( - - } - noPadding - titleTypographyProps={{ component: 'h2' }} - > - - - )} - + ); diff --git a/plugins/scaffolder/src/next/types.ts b/plugins/scaffolder/src/next/types.ts index e9c77aa00e..c2ee473d54 100644 --- a/plugins/scaffolder/src/next/types.ts +++ b/plugins/scaffolder/src/next/types.ts @@ -23,7 +23,7 @@ import type { FormProps as SchemaFormProps } from '@rjsf/core-v5'; /** - * Any `@rjsf/core` form properties that are publicly exposed to the `NextScaffolderpage` + * Any `@rjsf/core` form properties that are publicly exposed to the `NextScaffolderPage` * * @alpha * @deprecated use the import from {@link @backstage/plugin-scaffolder-react/alpha#FormProps} instead diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx index 26521ea9ee..54784b1f4e 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx @@ -33,7 +33,7 @@ import { MissingApiKeyOrApiIdError } from './Errors'; import { EscalationPolicy } from './Escalation'; import { Incidents } from './Incident'; import { TriggerDialog } from './TriggerDialog'; -import { RoutingKey, Team, User } from './types'; +import { RoutingKey, User } from './types'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { @@ -219,37 +219,6 @@ export const EntitySplunkOnCallCard = (props: EntitySplunkOnCallCardProps) => { ); } - const Content = ({ - team, - routingKey, - usersHashMap, - }: { - team: Team | undefined; - routingKey: RoutingKey | undefined; - usersHashMap: any; - }) => { - const teamName = team?.name ?? ''; - - return ( - <> - - {usersHashMap && team && ( - - )} - - - ); - }; - const triggerLink: IconLinkVerticalProps = { label: 'Create Incident', onClick: handleDialog, @@ -267,30 +236,45 @@ export const EntitySplunkOnCallCard = (props: EntitySplunkOnCallCardProps) => { return ( <> - {teams.map((team, i) => ( - - - Team: {team && team.name ? team.name : ''} - , - , - ]} - /> - - - { + const teamName = team?.name ?? ''; + return ( + + + Team: {team && team.name ? team.name : ''} + , + , + ]} /> - - - ))} + + + + {entityData?.usersHashMap && team && ( + + )} + + + + ); + })} ); }; diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts index 8866b5badb..e29494be8b 100644 --- a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts @@ -141,7 +141,9 @@ export class JsonRulesEngineFactChecker ); } catch (e) { if (isError(e)) { - throw new Error(`Failed to run rules engine, ${e.message}`); + throw new Error(`Failed to run rules engine, ${e.message}`, { + cause: e, + }); } throw e; } diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.ts b/plugins/techdocs-node/src/stages/publish/awsS3.ts index a13baef926..6b528cfd30 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.ts @@ -152,7 +152,7 @@ export class AwsS3Publish implements PublisherBase { // AWS forcePathStyle is an optional config. If missing, it defaults to false. Needs to be enabled for cases // where endpoint url points to locally hosted S3 compatible storage like Localstack - const s3ForcePathStyle = config.getOptionalBoolean( + const forcePathStyle = config.getOptionalBoolean( 'techdocs.publisher.awsS3.s3ForcePathStyle', ); @@ -161,7 +161,7 @@ export class AwsS3Publish implements PublisherBase { credentialDefaultProvider: () => sdkCredentialProvider, ...(region && { region }), ...(endpoint && { endpoint }), - ...(s3ForcePathStyle && { s3ForcePathStyle }), + ...(forcePathStyle && { forcePathStyle }), }); const legacyPathCasing = diff --git a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts index 896f909be4..e222c24a4c 100644 --- a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts +++ b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.test.ts @@ -15,46 +15,104 @@ */ import { scrollIntoAnchor } from '../transformers'; - -jest.useFakeTimers(); +import { createTestShadowDom } from '../../test-utils'; +import { Transformer } from './transformer'; +import mkdocsIndex from '../../test-utils/fixtures/mkdocs-index'; +import { SHADOW_DOM_STYLE_LOAD_EVENT } from '@backstage/plugin-techdocs-react'; describe('scrollIntoAnchor', () => { - const transformer = scrollIntoAnchor(); - const dom = { querySelector: jest.fn() }; + const scrollIntoView = jest.fn(); + let querySelectorSpy: jest.SpyInstance; + let addEventListenerSpy: jest.SpyInstance; + let removeEventListenerSpy: jest.SpyInstance; + const applySpies: Transformer = dom => { + querySelectorSpy = jest.spyOn(dom, 'querySelector'); + querySelectorSpy.mockReturnValue({ scrollIntoView }); + addEventListenerSpy = jest.spyOn(dom, 'addEventListener'); + removeEventListenerSpy = jest.spyOn(dom, 'removeEventListener'); + return dom; + }; afterEach(() => { jest.clearAllMocks(); }); it('does nothing if there is no anchor element', async () => { - transformer(dom as unknown as Element); - jest.advanceTimersByTime(200); - expect(dom.querySelector).not.toHaveBeenCalled(); + await createTestShadowDom(mkdocsIndex, { + preTransformers: [], + postTransformers: [applySpies, scrollIntoAnchor()], + }); + expect(querySelectorSpy).not.toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalled(); + expect(removeEventListenerSpy).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + // check that the same function is passed to both addEventListener and removeEventListener + expect(addEventListenerSpy.mock.calls[0][1]).toBe( + removeEventListenerSpy.mock.calls[0][1], + ); }); it('scroll to the hash anchor element', async () => { - const scrollIntoView = jest.fn(); - dom.querySelector.mockReturnValue({ scrollIntoView }); window.location.hash = '#hash'; - transformer(dom as unknown as Element); - jest.advanceTimersByTime(200); - expect(dom.querySelector).toHaveBeenCalledWith( + await createTestShadowDom(mkdocsIndex, { + preTransformers: [], + postTransformers: [applySpies, scrollIntoAnchor()], + }); + expect(querySelectorSpy).toHaveBeenCalledWith( expect.stringMatching('[id="hash"]'), ); expect(scrollIntoView).toHaveBeenCalledWith(); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + // check that the same function is passed to both addEventListener and removeEventListener + expect(addEventListenerSpy.mock.calls[0][1]).toBe( + removeEventListenerSpy.mock.calls[0][1], + ); window.location.hash = ''; }); it('works for anchor starting with number', async () => { - const scrollIntoView = jest.fn(); - dom.querySelector.mockReturnValue({ scrollIntoView }); + querySelectorSpy.mockReturnValue({ scrollIntoView }); window.location.hash = '#1-hash'; - transformer(dom as unknown as Element); - jest.advanceTimersByTime(200); - expect(dom.querySelector).toHaveBeenCalledWith( + await createTestShadowDom(mkdocsIndex, { + preTransformers: [], + postTransformers: [applySpies, scrollIntoAnchor()], + }); + expect(querySelectorSpy).toHaveBeenCalledWith( expect.stringMatching('[id="1-hash"]'), ); expect(scrollIntoView).toHaveBeenCalledWith(); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + SHADOW_DOM_STYLE_LOAD_EVENT, + expect.any(Function), + ); + // check that the same function is passed to both addEventListener and removeEventListener + expect(addEventListenerSpy.mock.calls[0][1]).toBe( + removeEventListenerSpy.mock.calls[0][1], + ); window.location.hash = ''; }); }); diff --git a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts index 7ba02bcdfc..97a18b7241 100644 --- a/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts +++ b/plugins/techdocs/src/reader/transformers/scrollIntoAnchor.ts @@ -15,17 +15,23 @@ */ import type { Transformer } from './transformer'; +import { SHADOW_DOM_STYLE_LOAD_EVENT } from '@backstage/plugin-techdocs-react'; export const scrollIntoAnchor = (): Transformer => { return dom => { - setTimeout(() => { - // Scroll to the desired anchor on initial navigation - if (window.location.hash) { - const hash = window.location.hash.slice(1); - // fix invalid selector error for anchor starting with number - dom?.querySelector(`[id="${hash}"]`)?.scrollIntoView(); - } - }, 200); + dom.addEventListener( + SHADOW_DOM_STYLE_LOAD_EVENT, + function handleShadowDomStyleLoad() { + if (window.location.hash) { + const hash = window.location.hash.slice(1); + dom?.querySelector(`[id="${hash}"]`)?.scrollIntoView(); + } + dom.removeEventListener( + SHADOW_DOM_STYLE_LOAD_EVENT, + handleShadowDomStyleLoad, + ); + }, + ); return dom; }; }; diff --git a/plugins/techdocs/src/reader/transformers/styles/rules/layout.ts b/plugins/techdocs/src/reader/transformers/styles/rules/layout.ts index c4e6938fdc..e6bad41045 100644 --- a/plugins/techdocs/src/reader/transformers/styles/rules/layout.ts +++ b/plugins/techdocs/src/reader/transformers/styles/rules/layout.ts @@ -105,7 +105,13 @@ export default ({ theme, sidebar }: RuleOptions) => ` .md-footer { position: fixed; bottom: 0px; + pointer-events: none; } + +.md-footer-nav__link { + pointer-events: all; +} + .md-footer__title { background-color: unset; } diff --git a/storybook/yarn.lock b/storybook/yarn.lock index 94167a34a7..7820ea2d4e 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -2966,90 +2966,90 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-darwin-arm64@npm:1.3.26" +"@swc/core-darwin-arm64@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-darwin-arm64@npm:1.3.27" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-darwin-x64@npm:1.3.26" +"@swc/core-darwin-x64@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-darwin-x64@npm:1.3.27" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.26" +"@swc/core-linux-arm-gnueabihf@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.27" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm64-gnu@npm:1.3.26" +"@swc/core-linux-arm64-gnu@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.27" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm64-musl@npm:1.3.26" +"@swc/core-linux-arm64-musl@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.27" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-x64-gnu@npm:1.3.26" +"@swc/core-linux-x64-gnu@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.27" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-x64-musl@npm:1.3.26" +"@swc/core-linux-x64-musl@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-x64-musl@npm:1.3.27" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-arm64-msvc@npm:1.3.26" +"@swc/core-win32-arm64-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.27" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-ia32-msvc@npm:1.3.26" +"@swc/core-win32-ia32-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.27" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-x64-msvc@npm:1.3.26" +"@swc/core-win32-x64-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.27" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/core@npm:^1.3.9": - version: 1.3.26 - resolution: "@swc/core@npm:1.3.26" + version: 1.3.27 + resolution: "@swc/core@npm:1.3.27" dependencies: - "@swc/core-darwin-arm64": 1.3.26 - "@swc/core-darwin-x64": 1.3.26 - "@swc/core-linux-arm-gnueabihf": 1.3.26 - "@swc/core-linux-arm64-gnu": 1.3.26 - "@swc/core-linux-arm64-musl": 1.3.26 - "@swc/core-linux-x64-gnu": 1.3.26 - "@swc/core-linux-x64-musl": 1.3.26 - "@swc/core-win32-arm64-msvc": 1.3.26 - "@swc/core-win32-ia32-msvc": 1.3.26 - "@swc/core-win32-x64-msvc": 1.3.26 + "@swc/core-darwin-arm64": 1.3.27 + "@swc/core-darwin-x64": 1.3.27 + "@swc/core-linux-arm-gnueabihf": 1.3.27 + "@swc/core-linux-arm64-gnu": 1.3.27 + "@swc/core-linux-arm64-musl": 1.3.27 + "@swc/core-linux-x64-gnu": 1.3.27 + "@swc/core-linux-x64-musl": 1.3.27 + "@swc/core-win32-arm64-msvc": 1.3.27 + "@swc/core-win32-ia32-msvc": 1.3.27 + "@swc/core-win32-x64-msvc": 1.3.27 dependenciesMeta: "@swc/core-darwin-arm64": optional: true @@ -3071,7 +3071,7 @@ __metadata: optional: true "@swc/core-win32-x64-msvc": optional: true - checksum: 656a2197bd8bd50b112077e8c7f9156527ec66cb06fed2cefb9e2610e3cde13e8416783f84edda67b9af4e27d6b169bc1a447d7c6c73cf64a1b3ec428b0737ab + checksum: 1e43612e65bd223e32db59497ea0bd1345a6da6ae1c4bb42556f1948da1175035c92b7a2b4dda76bfd4f7b229e885756cd9ec36af876e4e3da3f48cb4ba2fa0d languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index bce9dd6418..60554f768c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,14 +22,14 @@ __metadata: linkType: hard "@apidevtools/json-schema-ref-parser@npm:^9.0.6": - version: 9.1.0 - resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.0" + version: 9.1.1 + resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.1" dependencies: "@jsdevtools/ono": ^7.1.3 "@types/json-schema": ^7.0.6 call-me-maybe: ^1.0.1 js-yaml: ^4.1.0 - checksum: 135569d905cd5cd3652fe850ff3a07bfbbfb1585bb20139710e8a865f0bd9fa1c8bbe8122cce11928ce00fc618b8b9f61ccd7b62827654c9b7e2853789ee0e4a + checksum: f8fb98fec4d510ef7a135256745b7321fbc1547a83cdeb458b143d46f3d5fae1450673a9f485f77051391d052e6ec9d6ec6c0d3091d7dfe4b6ac5d85ee77e10d languageName: node linkType: hard @@ -4989,6 +4989,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@types/express": ^4.17.6 "@types/luxon": ^3.0.0 @@ -7075,6 +7076,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" "@types/command-exists": ^1.2.0 "@types/fs-extra": ^9.0.1 @@ -7098,6 +7100,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" "@types/command-exists": ^1.2.0 "@types/fs-extra": ^9.0.1 @@ -7120,7 +7123,7 @@ __metadata: "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" - "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/test-utils": "workspace:^" "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 @@ -7140,7 +7143,7 @@ __metadata: "@backstage/backend-common": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" - "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" winston: ^3.2.1 yeoman-environment: ^3.9.1 @@ -7165,6 +7168,7 @@ __metadata: "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" "@gitbeaker/core": ^35.6.0 "@gitbeaker/node": ^35.1.0 @@ -7224,6 +7228,20 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-scaffolder-node@workspace:^, @backstage/plugin-scaffolder-node@workspace:plugins/scaffolder-node": + version: 0.0.0-use.local + resolution: "@backstage/plugin-scaffolder-node@workspace:plugins/scaffolder-node" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-scaffolder-common": "workspace:^" + "@backstage/types": "workspace:^" + jsonschema: ^1.2.6 + winston: ^3.2.1 + languageName: unknown + linkType: soft + "@backstage/plugin-scaffolder-react@workspace:^, @backstage/plugin-scaffolder-react@workspace:plugins/scaffolder-react": version: 0.0.0-use.local resolution: "@backstage/plugin-scaffolder-react@workspace:plugins/scaffolder-react" @@ -7262,6 +7280,7 @@ __metadata: json-schema-library: ^7.3.9 lodash: ^4.17.21 qs: ^6.9.4 + react-use: ^17.2.4 zen-observable: ^0.10.0 zod: ~3.18.0 zod-to-json-schema: ~3.18.0 @@ -13282,90 +13301,90 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-darwin-arm64@npm:1.3.26" +"@swc/core-darwin-arm64@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-darwin-arm64@npm:1.3.27" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-darwin-x64@npm:1.3.26" +"@swc/core-darwin-x64@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-darwin-x64@npm:1.3.27" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.26" +"@swc/core-linux-arm-gnueabihf@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.27" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm64-gnu@npm:1.3.26" +"@swc/core-linux-arm64-gnu@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.27" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-arm64-musl@npm:1.3.26" +"@swc/core-linux-arm64-musl@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.27" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-x64-gnu@npm:1.3.26" +"@swc/core-linux-x64-gnu@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.27" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-linux-x64-musl@npm:1.3.26" +"@swc/core-linux-x64-musl@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-linux-x64-musl@npm:1.3.27" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-arm64-msvc@npm:1.3.26" +"@swc/core-win32-arm64-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.27" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-ia32-msvc@npm:1.3.26" +"@swc/core-win32-ia32-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.27" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.3.26": - version: 1.3.26 - resolution: "@swc/core-win32-x64-msvc@npm:1.3.26" +"@swc/core-win32-x64-msvc@npm:1.3.27": + version: 1.3.27 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.27" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/core@npm:^1.3.9": - version: 1.3.26 - resolution: "@swc/core@npm:1.3.26" + version: 1.3.27 + resolution: "@swc/core@npm:1.3.27" dependencies: - "@swc/core-darwin-arm64": 1.3.26 - "@swc/core-darwin-x64": 1.3.26 - "@swc/core-linux-arm-gnueabihf": 1.3.26 - "@swc/core-linux-arm64-gnu": 1.3.26 - "@swc/core-linux-arm64-musl": 1.3.26 - "@swc/core-linux-x64-gnu": 1.3.26 - "@swc/core-linux-x64-musl": 1.3.26 - "@swc/core-win32-arm64-msvc": 1.3.26 - "@swc/core-win32-ia32-msvc": 1.3.26 - "@swc/core-win32-x64-msvc": 1.3.26 + "@swc/core-darwin-arm64": 1.3.27 + "@swc/core-darwin-x64": 1.3.27 + "@swc/core-linux-arm-gnueabihf": 1.3.27 + "@swc/core-linux-arm64-gnu": 1.3.27 + "@swc/core-linux-arm64-musl": 1.3.27 + "@swc/core-linux-x64-gnu": 1.3.27 + "@swc/core-linux-x64-musl": 1.3.27 + "@swc/core-win32-arm64-msvc": 1.3.27 + "@swc/core-win32-ia32-msvc": 1.3.27 + "@swc/core-win32-x64-msvc": 1.3.27 dependenciesMeta: "@swc/core-darwin-arm64": optional: true @@ -13387,7 +13406,7 @@ __metadata: optional: true "@swc/core-win32-x64-msvc": optional: true - checksum: 656a2197bd8bd50b112077e8c7f9156527ec66cb06fed2cefb9e2610e3cde13e8416783f84edda67b9af4e27d6b169bc1a447d7c6c73cf64a1b3ec428b0737ab + checksum: 1e43612e65bd223e32db59497ea0bd1345a6da6ae1c4bb42556f1948da1175035c92b7a2b4dda76bfd4f7b229e885756cd9ec36af876e4e3da3f48cb4ba2fa0d languageName: node linkType: hard @@ -13482,15 +13501,15 @@ __metadata: languageName: unknown linkType: soft -"@testing-library/cypress@npm:^8.0.2": - version: 8.0.7 - resolution: "@testing-library/cypress@npm:8.0.7" +"@testing-library/cypress@npm:^9.0.0": + version: 9.0.0 + resolution: "@testing-library/cypress@npm:9.0.0" dependencies: "@babel/runtime": ^7.14.6 "@testing-library/dom": ^8.1.0 peerDependencies: - cypress: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - checksum: e005bc1a7ec808706c57e95ed312069fb5be39ea7362900dc2a32c09f124d478ade69ebcd7df88c076e3867ab328ae6e6ce13791bdf042621ff66b56552bf74b + cypress: ^12.0.0 + checksum: fbd24e8f0b8a60279b336de5f6bc0e7ad6fb31316eacab5128dacc7fccde1eb40935b90f2c3bddc7d814115fe3965c6dbf011785448cd15b5a5b0bc40ef5bb4c languageName: node linkType: hard @@ -15714,9 +15733,9 @@ __metadata: languageName: node linkType: hard -"@uiw/codemirror-extensions-basic-setup@npm:4.19.5": - version: 4.19.5 - resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.19.5" +"@uiw/codemirror-extensions-basic-setup@npm:4.19.7": + version: 4.19.7 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.19.7" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/commands": ^6.0.0 @@ -15733,19 +15752,19 @@ __metadata: "@codemirror/search": ">=6.0.0" "@codemirror/state": ">=6.0.0" "@codemirror/view": ">=6.0.0" - checksum: adc49a56f592d34744c67eabdd878512a92387c8d10c6b4fa1cf947a8af1e5075308149d3bcc69778fa78a78f58a40e1b604615167b1374c917408cb50cfcccc + checksum: 4c32d3b41b78776fd41b229edc8777dff4137ff6125836df5a6573e01a0a74f2ae66293a09a3b095a1fbe660b2fc8b2bde93ba4aa92636702a74baea261fcf94 languageName: node linkType: hard "@uiw/react-codemirror@npm:^4.9.3": - version: 4.19.5 - resolution: "@uiw/react-codemirror@npm:4.19.5" + version: 4.19.7 + resolution: "@uiw/react-codemirror@npm:4.19.7" dependencies: "@babel/runtime": ^7.18.6 "@codemirror/commands": ^6.1.0 "@codemirror/state": ^6.1.1 "@codemirror/theme-one-dark": ^6.0.0 - "@uiw/codemirror-extensions-basic-setup": 4.19.5 + "@uiw/codemirror-extensions-basic-setup": 4.19.7 codemirror: ^6.0.0 peerDependencies: "@babel/runtime": ">=7.11.0" @@ -15755,7 +15774,7 @@ __metadata: codemirror: ">=6.0.0" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 5fb99c3599dabf9b52331016e6b42f790afc3858b3c8f034044f8fca9d52a1b6230a0df2ef21df4f2fae86856a4c1a3134d09a40ae3cd81084288dfab958f4d4 + checksum: 333c7b0c7181219bb0506cd956eb10fa07aad5f26d3c5a9e7b9875e6c60f1e9bf1c8f74bf160735c75b7dbe9e9b05879c17f5ea641e212ba8f454887f04b0e7e languageName: node linkType: hard @@ -21831,8 +21850,8 @@ __metadata: linkType: hard "eslint-plugin-react@npm:^7.28.0": - version: 7.32.0 - resolution: "eslint-plugin-react@npm:7.32.0" + version: 7.32.1 + resolution: "eslint-plugin-react@npm:7.32.1" dependencies: array-includes: ^3.1.6 array.prototype.flatmap: ^1.3.1 @@ -21851,7 +21870,7 @@ __metadata: string.prototype.matchall: ^4.0.8 peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - checksum: b81ce2623b50a936287d8e21997bd855094e643856c99b42a9f0c10e1c7b123e469c3d75f77df9eefb719fee2b47a763862f1cdca1e7cc26edc7cde2fb8cba87 + checksum: e20eab61161a3db6211c2bd1eb9be3e407fd14e72c06c5f39a078b6ac37427b2af6056ee70e3954249bca0a04088ae797a0c8ba909fb8802e29712de2a41262d languageName: node linkType: hard @@ -22231,7 +22250,7 @@ __metadata: "@roadiehq/backstage-plugin-github-insights": ^2.0.5 "@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7 "@roadiehq/backstage-plugin-travis-ci": ^2.0.5 - "@testing-library/cypress": ^8.0.2 + "@testing-library/cypress": ^9.0.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 @@ -29581,8 +29600,8 @@ __metadata: linkType: hard "msw@npm:^0.49.0": - version: 0.49.2 - resolution: "msw@npm:0.49.2" + version: 0.49.3 + resolution: "msw@npm:0.49.3" dependencies: "@mswjs/cookies": ^0.2.2 "@mswjs/interceptors": ^0.17.5 @@ -29600,7 +29619,7 @@ __metadata: node-fetch: ^2.6.7 outvariant: ^1.3.0 path-to-regexp: ^6.2.0 - strict-event-emitter: ^0.2.6 + strict-event-emitter: ^0.4.3 type-fest: ^2.19.0 yargs: ^17.3.1 peerDependencies: @@ -29610,7 +29629,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10ec35671e10e53c2a24ca22c5e7a2262d4cdfb409a403a3ffe221150af404805490e7661611a581986e61790bb8996ce8a0f057d521ef359498304c2431234a + checksum: 8322cd42cd69f289c05517d02bde22fc2f10e86fc2d0d209d9df54bd03d10b8123723c5587a2654dcd2cd0f314a016f9eccac88cffa30fafd1f9fead16db639e languageName: node linkType: hard @@ -35779,7 +35798,7 @@ __metadata: languageName: node linkType: hard -"strict-event-emitter@npm:^0.2.0, strict-event-emitter@npm:^0.2.4, strict-event-emitter@npm:^0.2.6": +"strict-event-emitter@npm:^0.2.0, strict-event-emitter@npm:^0.2.4": version: 0.2.7 resolution: "strict-event-emitter@npm:0.2.7" dependencies: @@ -35788,6 +35807,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.4.3": + version: 0.4.3 + resolution: "strict-event-emitter@npm:0.4.3" + checksum: ee335aba8a43bc6749d3337d78b9f6dbe8358d34b6eb38e7075dadf7f1cae621d96588663dda22dce17e6e7c7e281ddac79ca47421da8250bb4bf53b979508a5 + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" @@ -36332,13 +36358,13 @@ __metadata: linkType: hard "swr@npm:^2.0.0": - version: 2.0.0 - resolution: "swr@npm:2.0.0" + version: 2.0.1 + resolution: "swr@npm:2.0.1" dependencies: use-sync-external-store: ^1.2.0 peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 - checksum: 4852d8318ed022a681b40a384e675f65e211104d62075c1250b02f47cc8b739cb9138a954e2191c700e2de22b010b9bd05a113c71b193abe0c351d3c4f4646d8 + checksum: 5466e46a80bccf722c212ea3f14668b4e7f047d958f3da7252fba3529e6b1773a1762aa4bbf43aec104cc50625dbd55380539eeef59fd5ad9ebca6ad0552f45d languageName: node linkType: hard