diff --git a/.changeset/breezy-badgers-train.md b/.changeset/breezy-badgers-train.md new file mode 100644 index 0000000000..8f747b8602 --- /dev/null +++ b/.changeset/breezy-badgers-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Upgraded @yarnpkg/parsers to stable 3.0 diff --git a/.changeset/calm-cars-serve.md b/.changeset/calm-cars-serve.md new file mode 100644 index 0000000000..5df2a18cd4 --- /dev/null +++ b/.changeset/calm-cars-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-tasks': patch +--- + +Marked all exports as deprecated and pointed at `@backstage/backend-plugin-api` and `@backstage/backend-defaults` diff --git a/.changeset/calm-plums-wink.md b/.changeset/calm-plums-wink.md new file mode 100644 index 0000000000..edb991f616 --- /dev/null +++ b/.changeset/calm-plums-wink.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +mkdocs-material have updated their CSS variable template, and a few are unset in Backstage. This patch adds the missing variables to ensure coverage. diff --git a/.changeset/cold-seas-end.md b/.changeset/cold-seas-end.md new file mode 100644 index 0000000000..96fa048fbd --- /dev/null +++ b/.changeset/cold-seas-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Updated configuration schema to include the `useRedisSets` cache config option. diff --git a/.changeset/cyan-paws-beg.md b/.changeset/cyan-paws-beg.md new file mode 100644 index 0000000000..87c2b746a4 --- /dev/null +++ b/.changeset/cyan-paws-beg.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Added handling for dry run to githubPullRequest and githubWebhook and added tests for this functionality diff --git a/.changeset/cyan-snails-peel.md b/.changeset/cyan-snails-peel.md new file mode 100644 index 0000000000..4da79258fb --- /dev/null +++ b/.changeset/cyan-snails-peel.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-user-settings-backend': patch +'@backstage/plugin-devtools-backend': patch +'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-search-backend': patch +'@backstage/plugin-proxy-backend': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-app-backend': patch +--- + +Updated local development setup. diff --git a/.changeset/eighty-kings-dress.md b/.changeset/eighty-kings-dress.md new file mode 100644 index 0000000000..52aab627ab --- /dev/null +++ b/.changeset/eighty-kings-dress.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +In preparation to the new backend system stable release, the `isDatabaseConflictError` helper have been moved to the `@backstage/backend-plugin-api` package and deprecated from `@backstage/backend-common`. diff --git a/.changeset/empty-spoons-tell.md b/.changeset/empty-spoons-tell.md new file mode 100644 index 0000000000..44e3dceb6f --- /dev/null +++ b/.changeset/empty-spoons-tell.md @@ -0,0 +1,46 @@ +--- +'@backstage/plugin-proxy-backend': minor +--- + +**BREAKING**: The proxy backend plugin is now protected by Backstage auth, by +default. Unless specifically configured (see below), all proxy endpoints will +reject requests immediately unless a valid Backstage user or service token is +passed along with the request. This aligns the proxy with how other Backstage +backends behave out of the box, and serves to protect your upstreams from +unauthorized access. + +A proxy configuration section can now look as follows: + +```yaml +proxy: + endpoints: + '/pagerduty': + target: https://api.pagerduty.com + credentials: require # NEW! + headers: + Authorization: Token token=${PAGERDUTY_TOKEN} +``` + +There are three possible `credentials` settings at this point: + +- `require`: Callers must provide Backstage user or service credentials with + each request. The credentials are not forwarded to the proxy target. +- `forward`: Callers must provide Backstage user or service credentials with + each request, and those credentials are forwarded to the proxy target. +- `dangerously-allow-unauthenticated`: No Backstage credentials are required to + access this proxy target. The target can still apply its own credentials + checks, but the proxy will not help block non-Backstage-blessed callers. If + you also add `allowedHeaders: ['Authorization']` to an endpoint configuration, + then the Backstage token (if provided) WILL be forwarded. + +The value `dangerously-allow-unauthenticated` was the old default. + +The value `require` is the new default, so requests that were previously +permitted may now start resulting in `401 Unauthorized` responses. If you have +`backend.auth.dangerouslyDisableDefaultAuthPolicy` set to `true`, this does not +apply; the proxy will behave as if all endpoints were set to +`dangerously-allow-unauthenticated`. + +If you have proxy endpoints that require unauthenticated access still, please +add `credentials: dangerously-allow-unauthenticated` to their declarations in +your app-config. diff --git a/.changeset/gentle-baboons-peel.md b/.changeset/gentle-baboons-peel.md new file mode 100644 index 0000000000..b51ad77283 --- /dev/null +++ b/.changeset/gentle-baboons-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +`TechDocsIndexPage` now accepts an optional `ownerPickerMode` for toggling the behavior of the `EntityOwnerPicker`, exposing a new mode `` particularly suitable for larger catalogs. In this new mode, `EntityOwnerPicker` will display all the users and groups present in the catalog. diff --git a/.changeset/loud-pumpkins-bow.md b/.changeset/loud-pumpkins-bow.md new file mode 100644 index 0000000000..a55c97e19a --- /dev/null +++ b/.changeset/loud-pumpkins-bow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Links that are rendered in the markdown in the `ScaffolderField` component are now opened in new tabs. diff --git a/.changeset/lovely-hats-pay.md b/.changeset/lovely-hats-pay.md new file mode 100644 index 0000000000..117f50e5db --- /dev/null +++ b/.changeset/lovely-hats-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': patch +--- + +Internal refactor to fix an issue where the MUI 5 `v5-` class prefixing gets removed by tree shaking. diff --git a/.changeset/lucky-taxis-rule.md b/.changeset/lucky-taxis-rule.md new file mode 100644 index 0000000000..eacbaaefbe --- /dev/null +++ b/.changeset/lucky-taxis-rule.md @@ -0,0 +1,9 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added core service factories and implementations from +`@backstage/backend-app-api`. They are now available as subpath exports, e.g. +`@backstage/backend-defaults/scheduler` is where the service factory and default +implementation of `coreServices.scheduler` now lives. They have been marked as +deprecated in their old locations. diff --git a/.changeset/many-moles-sing.md b/.changeset/many-moles-sing.md new file mode 100644 index 0000000000..8b49f674c7 --- /dev/null +++ b/.changeset/many-moles-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +We are deprecating the legacy `createServiceBuilder` factory, so if you are still using it, please checkout the migration guide and [migrate](https://backstage.io/docs/backend-system/building-plugins-and-modules/migrating) your plugin to use the new backend system. diff --git a/.changeset/new-numbers-hug.md b/.changeset/new-numbers-hug.md new file mode 100644 index 0000000000..e4e35a2825 --- /dev/null +++ b/.changeset/new-numbers-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Moved the declaration of the `SchedulerService` here, along with prefixed versions of all of the types it depends on, from `@backstage/backend-tasks` diff --git a/.changeset/nine-ties-type.md b/.changeset/nine-ties-type.md new file mode 100644 index 0000000000..abb0661724 --- /dev/null +++ b/.changeset/nine-ties-type.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/backend-app-api': patch +--- + +Fixing issue with log meta fields possibly being circular refs diff --git a/.changeset/rude-kings-press.md b/.changeset/rude-kings-press.md new file mode 100644 index 0000000000..5c10753f8a --- /dev/null +++ b/.changeset/rude-kings-press.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-app-api': patch +--- + +Deprecated core service factories and implementations and moved them over to +subpath exports on `@backstage/backend-defaults` instead. E.g. +`@backstage/backend-defaults/scheduler` is where the service factory and default +implementation of `coreServices.scheduler` now lives. diff --git a/.changeset/seven-geese-raise.md b/.changeset/seven-geese-raise.md new file mode 100644 index 0000000000..3d6e2347b9 --- /dev/null +++ b/.changeset/seven-geese-raise.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-backend-node': patch +'@backstage/plugin-search-backend': patch +--- + +Split backend search plugin startup into "init" and "start" stages to ensure necessary initialization has happened before startup diff --git a/.changeset/smooth-gifts-nail.md b/.changeset/smooth-gifts-nail.md new file mode 100644 index 0000000000..aa2585bc4f --- /dev/null +++ b/.changeset/smooth-gifts-nail.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/backend-app-api': patch +--- + +Updating the logger redaction message to something less dramatic diff --git a/.changeset/spicy-camels-happen.md b/.changeset/spicy-camels-happen.md new file mode 100644 index 0000000000..1017f042ad --- /dev/null +++ b/.changeset/spicy-camels-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +We are deprecating the legacy router handlers and contexts in preparation for the new backend system stable release. diff --git a/.changeset/tiny-pandas-return.md b/.changeset/tiny-pandas-return.md new file mode 100644 index 0000000000..cc9210a368 --- /dev/null +++ b/.changeset/tiny-pandas-return.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +`DefaultApiExplorerPage` now accepts an optional `ownerPickerMode` for toggling the behavior of the `EntityOwnerPicker`, exposing a new mode `` particularly suitable for larger catalogs. In this new mode, `EntityOwnerPicker` will display all the users and groups present in the catalog. diff --git a/.changeset/wet-crabs-guess.md b/.changeset/wet-crabs-guess.md new file mode 100644 index 0000000000..c8a9282a47 --- /dev/null +++ b/.changeset/wet-crabs-guess.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/plugin-catalog-backend': patch +--- + +Start using the `isDatabaseConflictError` helper from the `@backstage/backend-plugin-api` package in order to avoid dependency with the soon to deprecate `@backstage/backend-common` package. diff --git a/.changeset/wild-doors-cheat.md b/.changeset/wild-doors-cheat.md new file mode 100644 index 0000000000..c302dde467 --- /dev/null +++ b/.changeset/wild-doors-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fix `versions:check --fix` when `yarn.lock` has multiple joint versions in the same section diff --git a/.changeset/wise-wasps-look.md b/.changeset/wise-wasps-look.md new file mode 100644 index 0000000000..34f52b3a7b --- /dev/null +++ b/.changeset/wise-wasps-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Change owner to project for azure host diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index aa11568564..77dd930a73 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -447,6 +447,7 @@ unregistering unregistration untracked upsert +upstreams upvote URIs url diff --git a/ADOPTERS.md b/ADOPTERS.md index 4a6f726fcd..2bb76a199e 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -154,7 +154,7 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/ | [Lendingkart](https://www.lendingkart.com/) | [Dinesh Rajpoot](mailto:dinesh.rajpoot@lendingkart.com) | Service catalog, Software templates to enforce best practices and tech insights to track mandates & migrations. | | [Meltwater](https://underthehood.meltwater.com) | [@spier](https://github.com/spier), [@remen](https://github.com/remen) | Improving developer experience by centralizing documentation and internal APIs. Goal: Foster InnerSource collaboration and speed up onboarding time in our 500+ people Product & Engineering org. | | [Doctolib](https://doctolib.engineering/) | [@djiit](https://github.com/djiit) | Rails modularization effort awareness, tech organization discoverability. Improving the daily workflows and collaboration processes of our engineers. | -| [Twilio](https://www.twilio.com) | [Kyle Smith](https://github.com/knksmith57) | Developer portal, universal software catalog, and centralized taxonomy platform. | +| [Twilio](https://www.twilio.com) | [Alec Jacobs](https://github.com/alecjacobs5401) | Developer portal, universal software catalog, and centralized taxonomy platform. | | [OVHcloud](https://www.ovhcloud.com/fr/) | [Jean-Philippe Blary](https://github.com/blaryjp), [Arnaud Bauer](mailto:arnaud.bauer@ovhcloud.com), [Flavien Chantelot](https://github.com/Dorn-) | We're providing Backstage to our collaborators to ease their daily jobs, and let them extends it using plugins. | | [Procter & Gamble](https://us.pg.com/) | [Binita Nayak](https://github.com/binitan), | P&G leverages Backstage to build internal developer portal to ensure developers' happiness. This developer portal shall act as single source of information needed by development teams to seamlessly create, find and maintain their software components/resources/documentation. | | [SANS Institute](https://www.sans.org) | [Christopher Klewin](mailto:cklewin@sans.org) | Developer portal for centralized visibility, reporting, and tooling across multiple organizations. | diff --git a/OWNERS.md b/OWNERS.md index f2e2cb7cf1..bf97c91685 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -22,14 +22,15 @@ Team: @backstage/catalog-maintainers Scope: The catalog plugin and catalog model -| Name | Organization | Team | GitHub | Discord | -| --------------- | ------------ | --------- | ----------------------------------------- | ------------------- | -| Rickard Dybeck | Spotify | Chipmunks | [alde](https://github.com/alde) | `rdybeck#8083` | -| Mike Blockley | Spotify | Chipmunks | [mikeyhc](https://github.com/mikeyhc) | `mikey-spot#5363` | -| Elon Jefferson | Spotify | Chipmunks | [Edje-C](https://github.com/Edje-C) | `elon-spotty#6086 ` | -| Nurit Izrailov | Spotify | Chipmunks | [nuritizra](https://github.com/nuritizra) | - | -| Hunter Dougless | Spotify | Chipmunks | [hntrdglss](https://github.com/hntrdglss) | `hntrdglss#1849` | -| Seve Kim | Spotify | Chipmunks | [sevedkim](https://github.com/sevedkim) | `seve#9951` | +| Name | Organization | Team | GitHub | Discord | +| -------------------- | ------------ | ------------- | ----------------------------------------------- | --------------- | +| Ben Lambert | Spotify | Cubic Belugas | [benjdlambert](https://github.com/benjdlambert) | `blam#2159` | +| Camila Loiola | Spotify | Cubic Belugas | [camilaibs](http://github.com/camilaibs) | `camilal#0226` | +| Fredrik Adelöw | Spotify | Cubic Belugas | [freben](https://github.com/freben) | `freben#3926` | +| Johan Haals | Spotify | Cubic Belugas | [jhaals](https://github.com/jhaals) | `Johan#0679` | +| Mihai Tabara | Spotify | Cubic Belugas | [MihaiTabara](http://github.com/MihaiTabara) | `mihait#3107` | +| Patrik Oldsberg | Spotify | Cubic Belugas | [Rugvip](https://github.com/Rugvip) | `Rugvip#0019` | +| Vincenzo Scamporlino | Spotify | Cubic Belugas | [vinzscam](http://github.com/vinzscam) | `vinzscam#6944` | ### Discoverability @@ -73,14 +74,15 @@ Team: @backstage/permission-maintainers Scope: The Permission Framework and plugins integrating with the permission framework -| Name | Organization | Team | GitHub | Discord | -| -------------------- | ------------ | --------------- | ------------------------------------------ | ------------- | -| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 | -| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 | -| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 | -| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 | -| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 | -| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 | +| Name | Organization | Team | GitHub | Discord | +| -------------------- | ------------ | ------------- | ----------------------------------------------- | --------------- | +| Ben Lambert | Spotify | Cubic Belugas | [benjdlambert](https://github.com/benjdlambert) | `blam#2159` | +| Camila Loiola | Spotify | Cubic Belugas | [camilaibs](http://github.com/camilaibs) | `camilal#0226` | +| Fredrik Adelöw | Spotify | Cubic Belugas | [freben](https://github.com/freben) | `freben#3926` | +| Johan Haals | Spotify | Cubic Belugas | [jhaals](https://github.com/jhaals) | `Johan#0679` | +| Mihai Tabara | Spotify | Cubic Belugas | [MihaiTabara](http://github.com/MihaiTabara) | `mihait#3107` | +| Patrik Oldsberg | Spotify | Cubic Belugas | [Rugvip](https://github.com/Rugvip) | `Rugvip#0019` | +| Vincenzo Scamporlino | Spotify | Cubic Belugas | [vinzscam](http://github.com/vinzscam) | `vinzscam#6944` | ### TechDocs diff --git a/beps/0001-notifications-system/README.md b/beps/0001-notifications-system/README.md index 150abec9d6..80751179dd 100644 --- a/beps/0001-notifications-system/README.md +++ b/beps/0001-notifications-system/README.md @@ -92,7 +92,7 @@ If the notification status is updated, the signal service shall emit a signal wi The role of the notifications plugin is to manage the lifecycle of notifications. The backend plugin provides an API for other backends to send notifications, as well as an accompanying [backend service](https://backstage.io/docs/backend-system/architecture/services). It also provides a separate API for the frontend plugin to read notifications for an individual user and manage the read status of notifications. -The notification backend stores notification using the [database service](https://backstage.io/docs/backend-system/core-services/index#database). In particular it needs to store the following information for each notification: +The notification backend stores notification using the [database service](https://backstage.io/docs/backend-system/core-services/index#database). In particular, it needs to store the following information for each notification: - ID - Recipients @@ -110,8 +110,9 @@ The notification backend stores notification using the [database service](https: - Topic (optional) - Scope (optional) - Icon (optional) + - Metadata (optional) -The recipients is **not** a list of users, but rather a filter that describes who should receive the notification. It must be possible to evaluate this filter in a database query, so that we can efficiently fetch all notifications for a given user. The same filter will also be used by the signal backend to determine which users should receive a signal. +The recipients are **not** a list of users, but rather a filter that describes who should receive the notification. It must be possible to evaluate this filter in a database query, so that we can efficiently fetch all notifications for a given user. The same filter will also be used by the signal backend to determine which users should receive a signal. The read date is a timestamp of marking the notifications as read by the user. If missing, the notification is still unread. @@ -147,6 +148,8 @@ The link is a relative or absolute URL. As an example, it can be used: - by an external system to request an action within an asynchronous task - by a BE plugin to provide link to other part of the Backstage UI (i.e. to the Catalog) +The metadata is an opaque JSON field, where an additional payload can be stored. The format of this data is owned by the notification sender and is tied to the notification topic, i.e. notifications sent from the source on the same topic should use a compatible metadata format. The primary purpose of this field is to allow for custom processing and rendering based on the additional metadata. + The additional links are an array of title-URL pairs. They can represent immediate actions on the notification (i.e. yes-no) or lead the user to additional details. The `notification-backend` does not provide any new permissions, since creating notifications can only be done by other backend plugins, while reading notifications can only be done by the authenticated user. It is possible that we want to add a permissions for reading notifications, in particular for admin and impersonation use cases, but that is not part of this proposal or the initial implementation. @@ -206,12 +209,16 @@ export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low'; export type NotificationPayload = { title: string; description?: string; - link: string; + link?: string; additionalLinks?: string[]; - severity: NotificationSeverity; + severity?: NotificationSeverity; topic?: string; scope?: string; icon?: string; + metadata?: Array<{ + type: string; + value: JsonValue; + }>; }; export type Notification = { @@ -236,7 +243,8 @@ interface NotificationService { } ``` -Each notification contains a human readable `title`, `origin` and optionally `link` for additional details. The `created`, `id`, `read` and `saved` properties are handled by the backend based and cannot be passed during the notification creation. +Each notification contains a human-readable `title`, `origin` and optionally `link` for additional details. The `created`, `id`, `read` and `saved` properties are handled by the backend based and cannot be passed during the notification creation. +Any optional additional details could be stored in `metadata`. We advise to provide the name to the type which contains the information about the context and the version, for example: 'core.icon.v1'. Calling `sendNotification` should never throw an error so that it doesn't block the current processing. Notifications should be considered as second-level citizens that are not critical if not delivered. diff --git a/beps/0008-docs-personas-framework-portal/README.md b/beps/0008-docs-personas-framework-portal/README.md new file mode 100644 index 0000000000..07468b7c4a --- /dev/null +++ b/beps/0008-docs-personas-framework-portal/README.md @@ -0,0 +1,347 @@ +--- +title: Improved Backstage Documentation with Personas +status: provisional +authors: + - '@waldirmontoya25' + - '@aramissennyeyd' +owners: +project-areas: + - core +creation-date: 2024-03-18 +--- + +# BEP: Enhancing Backstage Documentation: Personas, Framework, and Developer Portal + +[**Discussion Issue**](https://github.com/backstage/backstage/issues/23689) + +- [BEP: Enhancing Backstage Documentation: Personas, Framework, and Developer Portal](#bep-enhancing-backstage-documentation-personas-framework-and-developer-portal) + - [Summary](#summary) + - [Motivation](#motivation) + - [Goals](#goals) + - [Non-Goals](#non-goals) + - [Proposal](#proposal) + - [Design Details](#design-details) + - [Personas](#personas) + - [User](#user) + - [Documentation Style](#documentation-style) + - [Administrator](#administrator) + - [Documentation Style](#documentation-style-1) + - [Integrator](#integrator) + - [Documentation Style](#documentation-style-2) + - [Contributor](#contributor) + - [Documentation Style](#documentation-style-3) + - [Business Stakeholder](#business-stakeholder) + - [Documentation Style](#documentation-style-4) + - [Release Plan](#release-plan) + - [Dependencies](#dependencies) + - [Example Table of Contents](#example-table-of-contents) + +## Summary + +This BEP proposes restructuring the Backstage documentation to emphasize the dual nature of Backstage as both a framework for building developer portals and a fully functional developer portal out of the box, as demonstrated by the demo site. The documentation will be divided into two main sections: One focusing on the developer portal that users get with the core plugins, and another on the framework that allows integrators and builders to create their own developer portal. The goal is to improve clarity, navigation, and adoption of Backstage by positioning it as both a ready-to-use developer portal and a framework for building custom developer portals. + +## Motivation + +The current Backstage documentation has been reported to be difficult to navigate, making it challenging for different personas across the DevEx ecosystem to extract value. The CNCF [**assessment**](https://github.com/cncf/techdocs/tree/main/assessments/0008-backstage/) and the resulting [**issues**](https://github.com/backstage/backstage/issues/21893) highlight the need for improvement in the documentation structure. + +### Goals + +- Divide the documentation into two section: Framework and Developer Portal +- Define the personas Backstage is targeting +- Structure the documentation to cater the different personas +- Move the existing content to the appropriate section + +### Non-Goals + +- Rewrite the entire documentation from scratch +- Write additional content beyond the scope of the existing documentation + +## Proposal + +The proposed restructuring of the Backstage documentation revolves around two core ideas: + +1. Positioning Backstage as both a framework to build developer portals and a developer portal itself, and splitting the documentation into two main sections: + + - Developer Portal: Focusing on the features, configuration, and usage of the developer portal that users get out of the box with the core plugins. + - Framework: Covering the aspects of Backstage as a framework, including guides for integrators and builders who want to create their own developer portal using Backstage. + +2. Defining the personas participating in Backstage adoption journeys to improve documentation navigation. The identified personas are: + + - **End User**: A person who uses Backstage to find information, use plugins, and consume the developer portal. + - **Administrator/Operator**: A person who configures, secures, and deploys the developer portal, manages plugins, and oversees the general administration of the developer portal. + - **Integrator/Builder**: A person who builds plugins, customizes the code and design, and creates custom-built developer portals based on the Backstage framework. This includes developers and designers and anyone adding new functionality to their own Backstage instance. + - **Product Manager/Business stakeholders**: A person who defines the strategy for adopting Backstage, identifies use cases, communicates the value proposition for adopting Backstage and connects the developer portal to the business strategy. + - **Contributor**: A person who contributes to the Backstage upstream ecosystem. + +The adoption strategy would be as follows: + +- Create a dedicated page describing the personas Backstage is targeting and the documentation sections that cater to each persona. +- Restructure the documentation into the two new sections (Framework and Developer Portal) and redistribute the existing content accordingly. +- Publish a blog post announcing the changes, highlighting Backstage's positioning as both a framework and a developer portal, and explaining the benefits of the restructured documentation. + +The benefits of restructuring the documentation according to these ideas include: + +1. Easier navigation and discoverability of information for different personas and use cases. +2. Clear separation of runtime and development documentation. +3. Simplified process for contributors to determine the appropriate location for new documentation. +4. Streamlined Backstage adoption process for new adopters. + +## Design Details + +- The Docs section of the microsite will be divided into two top-level sections: Framework and Developer Portal. +- The structure of the Table of Contents will align with the outline proposed in https://github.com/backstage/backstage/issues/21946. + +### Personas + +#### User + +Users navigate the developer portal to access tools, information, and plugins essential for their daily tasks. They rely on Backstage to effortlessly find resources, utilize integrations, and connect with other tools and services within their ecosystem. Their interaction is predominantly with the frontend of the portal, where ease of use, accessibility, and relevant content discovery are critical. + +##### Documentation Style + +Documentation for this persona should be about usability of the portal once it is running. For example: + +- Understanding the mechanics of the Software Catalog +- Registering components +- Deleting components +- How the source of truth is the external tool linked through the plugins +- Understand dependencies relations and the overall schema of the catalog +- Using available scaffolder actions +- Customizing new workflows with available actions +- Searching +- Using available plugins +- Step by step tutorials + +#### Administrator + +Administrators are responsible for the behind-the-scenes technical setup and maintenance of the Backstage portal. This includes deploying the portal, configuring plugins, managing user access, and ensuring the security and performance of the system. They interact with both the frontend and backend, often using command-line tools, administrative dashboards, and configuration files to perform their tasks. + +##### Documentation Style + +Documentation written for this persona should be DevOps technical, assuming a strong DevOps background. The goal with administrator documentation is to give administrators a strong understanding of how to deploy and manage a Backstage Developer Portal, best practices. For example: + +- Installing and upgrading +- Configuring + - Authentication + - Plugins +- Ingesting data (users/groups/components, etc) +- Installing plugins +- Implement Git Flows for the Developer portal +- Creating Pipelines for Docs generation +- Troubleshooting + +#### Integrator + +Integrators actively work on extending and customizing Backstage. This includes developing new plugins, customizing the UI/UX, and integrating external services or data sources. Their work is deeply technical, involving coding, and engaging with the Backstage community for support and collaboration. They need a deep understanding of the Backstage architecture and APIs, working closely with both the framework's backend and frontend aspects. + +##### Documentation Style + +Documentation written for this persona should be software technical, assuming a strong software background. While we can assume an overall technical knowledge, where possible we should link out to useful guides for the technologies we use, ex: Node.js, express.js, React, etc. The goal with documentation written for integrators is to give them a strong understanding of how to use the Backstage framework to build/evolve a company's Backstage Developer Portal, orient them to get support from the open source community, and prepare them for continuing to deliver value for their Backstage Developer Portal. For example: + +- API references +- Frontend and Backend systems +- Package architecture +- Extending the Software Catalog +- Creating custom themes +- Integrating new react components +- Building custom authentication providers/strategies +- Accessibility + +#### Contributor + +Contributors are involved in the development of the Backstage framework itself. They contribute to the core codebase, develop new features, fix bugs, create documentation and maintain the overall health of the project. They are deeply involved in the open-source community, collaborating with maintainers and other contributors to improve the framework and its ecosystem. + +##### Documentation Style + +The goal with documentation written for contributors is to give them a strong understanding of how to contribute to the Backstage framework, orient them to get support from the open source community, and prepare them for continuing to deliver value for the Backstage framework. For example: + +- Contributing to the Backstage framework +- Setting up a development environment +- Writing tests +- Writing documentation + +#### Business Stakeholder + +Business stakeholders use Backstage to align technical capabilities with business goals, monitoring how features and plugins support operational efficiency, developer satisfaction, and strategic objectives. They are involved in defining the strategy and measuring the impact of the developer portal on the organization. They need to navigate through dashboards, reports, and analytics within Backstage to gather insights and make informed decisions. + +##### Documentation Style + +Documentation written for this persona should be strategic, assuming a strong background in business development and strategy. The goal for business documentation is to give a strong understanding of what Backstage Developer Portal can do for their company, how to deliver value quickly and continuously and guides for pitching or driving Backstage adoption. For example: + +- Adoption use cases +- Adoption strategies +- Measuring success +- Case studies + +## Release Plan + +- Release the BEP by 03/24/2024. +- Discuss the changes with the community and gather feedback by 04/24/2024. +- Implement the changes by 04/30/2024. + +## Dependencies + +None + +## Example Table of Contents + +- Overview + - "The overview should introduce users to the concept of Backstage, what an IDP is, how to deliver value, why you should care about DevEx, etc." + - What is Backstage? + - Roadmap + - Vision + - Release and Versioning Policy + - Backstage Threat Model + - Logo assets + - Support and community +- Framework + - Architecture Overview + - "The arch overview should explain how the framework is structured, where plugins and instances fit in and how to understand the current design of Backstage." + - Getting Started + - Integrator/Builder Guides + - Local Development + - "Prepare users for how to develop locally, debug problems, run tests, etc." + - CLI + - Linking in local packages + - Debugging Backstage + - Backstage core framework + - "Internal documentation." + - Systems + - Frontend + - Old + - New + - Backend + - Old + - New + - API Reference + - "Internal documentation" + - Building plugins + - "How to build a plugin, how to integrate it with other plugins, how to deploy and monitor it, and how to iterate on plugin development." + - Intro to plugins + - Existing plugins + - Creating a new plugin + - Plugin development + - Structuring a plugin + - Integrating with other systems + - Integrating with the Catalog + - Integrating Search + - Composability system + - Internationalization + - Plugin analytics + - Feature flags + - OpenAPI + - Backends and APIs + - Testing + - Publishing + - Core Plugins + - "How to leverage the existing plugins for your new plugin or customization options." + - Home Page + - Customizing the home page + - Software Catalog + - Extending the model + - External integrations + - Catalog Customization + - API + - Software Templates + - Writing custom actions + - Writing tests for actions + - Writing custom field extensions + - Writing custom step layouts + - Authorizing parameters, steps and actions + - Migrating to react-jsonschema-form@v5 + - Migrating to v1beta3 templates + - Search + - Overview + - Getting Started with search + - Search concepts + - Search architecture + - Search Engines + - How to Guides + - TechDocs + - Customizing TechDocs + - TechDocs add-ons + - Kubernetes + - Customizing the kubernetes plugin + - Authentication + - Proxy + - Permissions + - Overview + - Concepts + - Getting Started + - Writing a permission policy + - Frontend integration + - Defining custom permission rules + - Using permissions in plugins + - Designing for Backstage + - ADRs + - Accessibility + - References + - Contributor Guides + - "How to get started contributing to OSS." + - Contributing to Backstage + - Reference +- Developer Portal + - Architecture Overview + - Getting Started + - Administrator Guides + - Developer Portal + - "How do I deploy, monitor, configure and verify my Backstage Developer Portal?" + - Installing and Configuring + - Database + - Authentication + - Installing plugins + - Customize the design + - Securing + - Deploying in Production + - Integrating with other systems + - Managing + - Monitoring + - Troubleshooting + - Upgrading + - Keeping backstage up to date + - Customizing + - Core Plugins + - "How do I install and configure Backstage Developer Portal with plugins." + - Home Page + - Installing and Configuring + - Software Catalog + - Overview + - The life of an Entity + - Catalog Configuration + - System Model + - YAML file format + - Entity Reference + - Well Known annotations + - Well known relations + - Well known statuses + - Creating the catalog graph + - Software Templates + - Overview + - Configuring + - Adding a new template + - Writing a template + - Built in actions + - TechDocs + - Overview + - Getting Started + - Architecture + - Installing and configuring + - Using Cloud Storage for TechDocs generated files + - Configuring CI/CD to generate and publish TechDocs sites + - TechDocs CLI + - Troubleshooting + - Kubernetes + - Installing and Configuring + - Authentication + - Troubleshooting + - Search + - Product Manager Guides + - "How do I present Backstage to leadership, what are the benefits, why should I care, etc." + - Strategies for adopting + - Use cases + - User Guides + - "How do I use the default OSS Backstage" + - Logging in + - Registering a component + - Creating a new component + - Reference diff --git a/docs/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md index a43e2307e6..d1dad0b183 100644 --- a/docs/auth/add-auth-provider.md +++ b/docs/auth/add-auth-provider.md @@ -4,10 +4,14 @@ title: Contributing New Providers description: Documentation on adding new authentication providers --- -> NOTE: The primary audience for this documentation are contributors to the main -> Backstage project that want to add support for new authentication providers. -> While you can follow it to implement your own custom providers it is much -> more advanced than using our built-in providers. +:::note Note + +The primary audience for this documentation are contributors to the main +Backstage project that want to add support for new authentication providers. +While you can follow it to implement your own custom providers it is much +more advanced than using our built-in providers. + +::: ## How Does Authentication Work? diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index c08411aca5..4213f8c0f9 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -156,8 +156,12 @@ payload: - `sub`: the exact string "backstage-server" - `exp`: one hour from the time it was generated, in epoch seconds -> NOTE: The JWT must encode the `alg` header as a protected header, such as with -> [setProtectedHeader](https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md#setprotectedheader). +:::note Note + +The JWT must encode the `alg` header as a protected header, such as with +[setProtectedHeader](https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md#setprotectedheader). + +::: The caller then passes along the JWT token with requests in the `Authorization` header: diff --git a/docs/backend-system/building-plugins-and-modules/08-migrating.md b/docs/backend-system/building-plugins-and-modules/08-migrating.md index 8a31251ee8..ae63b887ec 100644 --- a/docs/backend-system/building-plugins-and-modules/08-migrating.md +++ b/docs/backend-system/building-plugins-and-modules/08-migrating.md @@ -214,3 +214,44 @@ The above module can then be installed by the integrator alongside the kubernete backend.add(import('@backstage/plugin-kubernetes-backend')); backend.add(import('@internal/gke-cluster-supplier')); ``` + +### Dev Server + +Follow the steps below to run your migrated plugin on a local development server: + +1. First, delete the `src/run.ts` and `src/service/standaloneServer.ts` files in case they exist (the `backstage-cli` previously used these files to run legacy backend plugins locally, but they are no longer required). + +2. Next, create a new development backend in the `dev/index.ts` file. The dev server is a lite version of a backend app that is mainly used to run your plugin locally, so a simple `kubernetes` backend local development server would look like this: + +```ts title="in dev/index.js" +// This package should be installed as a `dev` dependency +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); +// Path to the file where the plugin is export as default +backend.add(import('../src')); +backend.start(); +``` + +The development server created above will be automatically configured with the default dependency factories, but if you need to mock some of the services your plugin relies on, such as the `rootConfig` service, you can use one of the `mockServices` factories: + +```ts title="in dev/index.js" +//... +// This package should be installed as `devDependecies` +import { mockServices } from '@backstage/backend-test-utils'; + +const backend = createBackend(); +// ... +backend.add( + mockServices.rootConfig.factory({ + data: { + // your config mocked values goes here + }, + }), +); +// ... +``` + +Checkout the [custom service implementations](https://backstage.io/docs/backend-system/building-backends/index#custom-service-implementations) documentation and also the [core service configurations](https://backstage.io/docs/backend-system/core-services/index) page in case you'd like to create your own custom mock factory for one or more services. + +3. Now you can finally start your plugin locally by running `yarn start` from the root folder of your plugin. diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 9ec5460a88..f16a2ad13e 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -141,8 +141,12 @@ browser at `http://localhost:7007` ## Multi-stage Build -> NOTE: The `.dockerignore` is different in this setup, read on for more -> details. +:::note Note + +The `.dockerignore` is different in this setup, read on for more +details. + +::: This section describes how to set up a multi-stage Docker build that builds the entire project within Docker. This is typically slower than a host build, but is @@ -293,10 +297,14 @@ browser at `http://localhost:7007` ## Separate Frontend -> NOTE: This is an optional step, and you will lose out on the features of the -> `@backstage/plugin-app-backend` plugin. Most notably the frontend configuration -> will no longer be injected by the backend, you will instead need to use the -> correct configuration when building the frontend bundle. +:::note Note + +This is an optional step, and you will lose out on the features of the +`@backstage/plugin-app-backend` plugin. Most notably the frontend configuration +will no longer be injected by the backend, you will instead need to use the +correct configuration when building the frontend bundle. + +::: It is sometimes desirable to serve the frontend separately from the backend, either from a separate image or for example a static file serving provider. The diff --git a/docs/deployment/index.md b/docs/deployment/index.md index da2c29ba23..0524e71db8 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -13,8 +13,12 @@ This documentation shows common examples that may be useful when deploying Backstage for the first time, or for those without established deployment practices. -> Note: The _easiest_ way to explore Backstage is to visit the -> [live demo site](https://demo.backstage.io). +:::note Note + +The _easiest_ way to explore Backstage is to visit the +[live demo site](https://demo.backstage.io). + +::: At Spotify, we deploy software generally by: diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 00bf7464e3..46ba273e14 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -107,10 +107,14 @@ $ echo -n "backstage" | base64 YmFja3N0YWdl ``` -> Note: Secrets are base64-encoded, but not encrypted. Be sure to enable -> [Encryption at Rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) -> for the cluster. For storing secrets in Git, consider -> [SealedSecrets or other solutions](https://learnk8s.io/kubernetes-secrets-in-git). +:::note Note + +Secrets are base64-encoded, but not encrypted. Be sure to enable +[Encryption at Rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) +for the cluster. For storing secrets in Git, consider +[SealedSecrets or other solutions](https://learnk8s.io/kubernetes-secrets-in-git). + +::: The secrets can now be applied to the Kubernetes cluster: diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 42ceaaa016..b3d03cc0b4 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -236,7 +236,11 @@ backend.add(kubernetesModuleCustomClusterDiscovery); backend.start(); ``` -> Note: this example assumes the `CustomClustersSupplier` class is the same from the [previous example](#custom-cluster-discovery) +:::note Note + +This example assumes the `CustomClustersSupplier` class is the same from the [previous example](#custom-cluster-discovery) + +::: ## Configuration diff --git a/docs/features/search/search-engines.md b/docs/features/search/search-engines.md index d06e401847..86b6244fb6 100644 --- a/docs/features/search/search-engines.md +++ b/docs/features/search/search-engines.md @@ -34,10 +34,14 @@ const searchEngine = new LunrSearchEngine({ logger: env.logger }); const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine }); ``` -> Note: Lunr is appropriate as a zero-config search engine when developing -> other parts of Backstage locally, however its use is highly discouraged when -> running Backstage in production. When deploying Backstage, use one of the -> other search engines instead. +:::note Note + +Lunr is appropriate as a zero-config search engine when developing +other parts of Backstage locally, however its use is highly discouraged when +running Backstage in production. When deploying Backstage, use one of the +other search engines instead. + +::: ## Postgres diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 3dc47a610a..0c8eb7bcda 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -95,7 +95,11 @@ const myColumnsFunc: CatalogTableColumnsFunc = entityListContext => { } /> ``` -> Note: the above example has been simplified and you will most likely have more code then just this in your `App.tsx` file. +:::note Note + +The above example has been simplified and you will most likely have more code then just this in your `App.tsx` file. + +::: ## Customize Actions @@ -162,7 +166,11 @@ const customActions: TableProps['actions'] = [ } /> ``` -> Note: the above example has been simplified and you will most likely have more code then just this in your `App.tsx` file. +:::note Note + +The above example has been simplified and you will most likely have more code then just this in your `App.tsx` file. + +::: The above customization will override the existing actions. Currently the only way to keep them and add your own is to also include the existing actions in your array by copying them from the [`defaultActions`](https://github.com/backstage/backstage/blob/57397e7d6d2d725712c439f4ab93f2ac6aa27bf8/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx#L113-L168). @@ -400,7 +408,11 @@ export const CustomCatalogPage = () => { The above is a very basic version of a fully custom `CatalogIndexPage`, you'll want to explore the various props to see what you can all do with them. This was built off the building blocks seen in the [`DefaultCatalogPage`](https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx) -> Note: The catalog index page is designed to have a minimal code footprint to support easy customization, but creating a replica does introduce a possibility of drifting out of date over time. Be sure to check the catalog [CHANGELOG](https://github.com/backstage/backstage/blob/master/plugins/catalog/CHANGELOG.md) periodically. +:::note Note + +The catalog index page is designed to have a minimal code footprint to support easy customization, but creating a replica does introduce a possibility of drifting out of date over time. Be sure to check the catalog [CHANGELOG](https://github.com/backstage/backstage/blob/master/plugins/catalog/CHANGELOG.md) periodically. + +::: To use this custom `CatalogIndexPage` which we called `CustomCatalogPage`, you'll need to make the following change: diff --git a/docs/features/software-catalog/extending-the-model--old.md b/docs/features/software-catalog/extending-the-model--old.md new file mode 100644 index 0000000000..7368a4a339 --- /dev/null +++ b/docs/features/software-catalog/extending-the-model--old.md @@ -0,0 +1,604 @@ +--- +id: extending-the-model--old +title: Extending the model +# prettier-ignore +description: Documentation on extending the catalog model +--- + +The Backstage catalog [entity data model](descriptor-format.md) is based on the +[Kubernetes objects format](https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/), +and borrows a lot of its semantics as well. This page describes those semantics +at a higher level and how to extend them to fit your organization. + +Backstage comes with a number of catalog concepts out of the box: + +- There are a number of builtin versioned _kinds_, such as `Component`, `User` + etc. These encapsulate the high level concept of an entity, and define the + schema for its entity definition data. +- An entity has both a _metadata_ object and a _spec_ object at the root. +- Each kind may or may not have a _type_. For example, there are several well + known types of component, such as `service` and `website`. These clarify the + more detailed nature of the entity, and may affect what features are exposed + in the interface. +- Entities may have a number of _[annotations](well-known-annotations.md)_ on + them. These can be added either by humans into the descriptor files, or added + by automated processes when the entity is ingested into the catalog. +- Entities may have a number of _labels_ on them. +- Entities may have a number of _relations_, expressing how they relate to each + other in different ways. + +We'll list different possibilities for extending this below. + +## Adding a New apiVersion of an Existing Kind + +Example intents: + +> "I want to evolve this core kind, tweaking the semantics a bit so I will bump +> the apiVersion a step" + +> "This core kind is a decent fit but we want to evolve it at will so we'll move +> it to our own company's apiVersion space and use that instead of +> `backstage.io`." + +The `backstage.io` apiVersion space is reserved for use by the Backstage +maintainers. Please do not change or add versions within that space. + +If you add an [apiVersion](descriptor-format.md#apiversion-and-kind-required) +space of your own, you are effectively branching out from the underlying kind +and making your own. An entity kind is identified by the apiVersion + kind pair, +so even though the resulting entity may be similar to the core one, there will +be no guarantees that plugins will be able to parse or understand its data. See +below about adding a new kind. + +## Adding a New Kind + +Example intents: + +> "The kinds that come with the package are lacking. I want to model this other +> thing that is a poor fit for either of the builtins." + +> "This core kind is a decent fit but we want to evolve it at will so we'll move +> it to our own company's apiVersion space and use that instead of +> `backstage.io`." + +A [kind](descriptor-format.md#apiversion-and-kind-required) is an overarching +family, or an idea if you will, of entities that also share a schema. Backstage +comes with a number of builtin ones that we believe are useful for a large +variety of needs that one may want to model in Backstage. The primary ambition +is to map things to these kinds, but sometimes you may want or need to extend +beyond them. + +Introducing a new apiVersion is basically the same as adding a new kind. Bear in +mind that most plugins will be compiled against the builtin +`@backstage/catalog-model` package and have expectations that kinds align with +that. + +The catalog backend itself, from a storage and API standpoint, does not care +about the kind of entities it stores. Extending with new kinds is mainly a +matter of permitting them to pass validation when building the backend catalog +using the `CatalogBuilder`, and then to make plugins be able to understand the +new kind. + +For the consuming side, it's a different story. Adding a kind has a very large +impact. The very foundation of Backstage is to attach behavior and views and +functionality to entities that we ascribe some meaning to. There will be many +places where code checks `if (kind === 'X')` for some hard coded `X`, and casts +it to a concrete type that it imported from a package such as +`@backstage/catalog-model`. + +If you want to model something that doesn't feel like a fit for either of the +builtin kinds, feel free to reach out to the Backstage maintainers to discuss +how to best proceed. + +If you end up adding that new kind, you must namespace its `apiVersion` +accordingly with a prefix that makes sense, typically based on your organization +name - e.g. `my-company.net/v1`. Also do pick a new `kind` identifier that does +not collide with the builtin kinds. + +## Adding a New Type of an Existing Kind + +Example intents: + +> "This is clearly a component, but it's of a type that doesn't quite fit with +> the ones I've seen before." + +> "We don't call our teams "team", can't we put "flock" as the group type?" + +Some entity kinds have a `type` field in its spec. This is where an organization +are free to express the variety of entities within a kind. This field is +expected to follow some taxonomy that makes sense for yourself. The chosen value +may affect what operations and views are enabled in Backstage for that entity. +Inside Spotify our model has grown significantly over the years, and our +component types now include ML models, apps, data pipelines and many more. + +It might be tempting to put software that doesn't fit into any of the existing +types into an Other catch-all type. There are a few reasons why we advise +against this; firstly, we have found that it is preferred to match the +conceptual model that your engineers have when describing your software. +Secondly, Backstage helps your engineers manage their software by integrating +the infrastructure tooling through plugins. Different plugins are used for +managing different types of components. + +For example, the +[Lighthouse plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/lighthouse/plugins/lighthouse) +only makes sense for Websites. The more specific you can be in how you model +your software, the easier it is to provide plugins that are contextual. + +Adding a new type takes relatively little effort and carries little risk. Any +type value is accepted by the catalog backend, but plugins may have to be +updated if you want particular behaviors attached to that new type. + +## Changing the Validation Rules for The Entity Envelope or Metadata Fields + +Example intents: + +> "We want to import our old catalog but the default set of allowed characters +> for a metadata.name are too strict." + +> "I want to change the rules for annotations so that I'm allowed to store any +> data in annotation values, not just strings." + +After pieces of raw entity data have been read from a location, they are passed +through a field format validation step. This ensures that the types and syntax +of the base envelope and metadata make sense - in short, things that aren't +entity-kind-specific. Some or all of these validators can be replaced when +building the backend using the catalog's dedicated `catalogModelExtensionPoint` +(or directly on the `CatalogBuilder` if you are still using the old backend +system). + +The risk and impact of this type of extension varies, based on what it is that +you want to do. For example, extending the valid character set for kinds, +namespaces and names can be fairly harmless, with a few notable exceptions - +there is code that expects these to never ever contain a colon or slash, for +example, and introducing URL-unsafe characters risks breaking plugins that +aren't careful about encoding arguments. Supporting non-strings in annotations +may be possible but has not yet been tried out in the real world - there is +likely to be some level of plugin breakage that can be hard to predict. + +You must also be careful about not making the rules _more strict_ than they used +to be after populating the catalog with data. This risks making previously valid +entities start having processing errors and fail to update. + +Before making this kind of extension, we recommend that you contact the +Backstage maintainers or a support partner to discuss your use case. + +This is an example of relaxing the format rules of the `metadata.name` field: + +```ts +import { createBackend } from '@backstage/backend-defaults'; +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; + +const myCatalogCustomizations = createBackendModule({ + pluginId: 'catalog', + moduleId: 'catalog-customization', + register(reg) { + reg.registerInit({ + deps: { + catalogModel: catalogModelExtensionPoint, + }, + async init({ catalogModel }) { + catalogModel.setFieldValidators({ + // This is only one of many methods that you can pass into + // setFieldValidators; your editor of choice should help you + // find the others. The length checks and regexp inside are + // just examples and can be adjusted as needed, but take care + // to test your changes thoroughly to ensure that you get + // them right. + isValidEntityName(value) { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 63 && + /^[A-Za-z0-9@+_.-]+$/.test(value) + ); + }, + }); + }, + }); + }, +}); + +const backend = createBackend(); +// ... add other backend features and the catalog backend itself here ... +backend.add(myCatalogCustomizations); +backend.start(); +``` + +## Changing the Validation Rules for Core Entity Fields + +Example intent: + +> "I don't like that the owner is mandatory. I'd like it to be optional." + +After reading and policy-checked entity data from a location, it is sent through +the processor chain looking for processors that implement the +`validateEntityKind` step, to see that the data is of a known kind and abides by +its schema. There is a builtin processor that implements this for all known core +kinds and matches the data against their fixed validation schema. This processor +can be replaced when building the backend catalog using the `CatalogBuilder`, +with a processor of your own that validates the data differently. +This replacement processor must have a name that matches the builtin processor, `BuiltinKindsEntityProcessor`. + +This type of extension is high risk, and may have high impact across the +ecosystem depending on the type of change that is made. It is therefore not +recommended in normal cases. There will be a large number of plugins and +processors - and even the core itself - that make assumptions about the shape of +the data and import the typescript data type from the `@backstage/catalog-model` +package. + +## Adding New Fields to the Metadata Object + +Example intent: + +> "Our entities have this auxiliary property that I would like to express for +> several entity kinds and it doesn't really fit as a spec field." + +The metadata object is currently left open for extension. Any unknown fields +found in the metadata will just be stored verbatim in the catalog. However we +want to caution against extending the metadata excessively. Firstly, you run the +risk of colliding with future extensions to the model. Secondly, it is common +that this type of extension lives more comfortably elsewhere - primarily in the +metadata labels or annotations, but sometimes you even may want to make a new +component type or similar instead. + +There are some situations where metadata can be the right place. If you feel +that you have run into such a case and that it would apply to others, do feel +free to contact the Backstage maintainers or a support partner to discuss your +use case. Maybe we can extend the core model to benefit both you and others. + +## Adding New Fields to the Spec Object of an Existing Kind + +Example intent: + +> "The builtin Component kind is fine but we want to add an additional field to +> the spec for describing whether it's in prod or staging." + +A kind's schema validation typically doesn't forbid "unknown" fields in an +entity `spec`, and the catalog will happily store whatever is in it. So doing +this will usually work from the catalog's point of view. + +Adding fields like this is subject to the same risks as mentioned about metadata +extensions above. Firstly, you run the risk of colliding with future extensions +to the model. Secondly, it is common that this type of extension lives more +comfortably elsewhere - primarily in the metadata labels or annotations, but +sometimes you even may want to make a new component type or similar instead. + +There are some situations where the spec can be the right place. If you feel +that you have run into such a case and that it would apply to others, do feel +free to contact the Backstage maintainers or a support partner to discuss your +use case. Maybe we can extend the core model to benefit both you and others. + +## Adding a New Annotation + +Example intents: + +> "Our custom made build system has the concept of a named pipeline-set, and we +> want to associate individual components with their corresponding pipeline-sets +> so we can show their build status." + +> "We have an alerting system that automatically monitors service health, and +> there's this integration key that binds the service to an alerts pool. We want +> to be able to show the ongoing alerts for our services in Backstage so it'd be +> nice to attach that integration key to the entity somehow." + +Annotations are mainly intended to be consumed by plugins, for feature detection +or linking into external systems. Sometimes they are added by humans, but often +they are automatically generated at ingestion time by processors. There is a set +of [well-known annotations](well-known-annotations.md), but you are free to add +additional ones. This carries no risk or impact to other systems as long as you +abide by the following naming rules. + +- The `backstage.io` annotation prefix is reserved for use by the Backstage + maintainers. Reach out to us if you feel that you would like to make an + addition to that prefix. +- Annotations that pertain to a well known third party system should ideally be + prefixed with a domain, in a way that makes sense to a reader and connects it + clearly to the system (or the maker of the system). For example, you might use + a `pagerduty.com` prefix for pagerduty related annotations, but maybe not + `ldap.com` for LDAP annotations since it's not directly affiliated with or + owned by an LDAP foundation/company/similar. +- Annotations that have no prefix at all, are considered local to your Backstage + instance and can be used freely as such, but you should not make use of them + outside of your organization. For example, if you were to open source a plugin + that generates or consumes annotations, then those annotations must be + properly prefixed with your company domain or a domain that pertains to the + annotation at hand. + +## Adding a New Label + +Example intents: + +> "Our process reaping system wants to periodically scrape for components that +> have a certain property." + +> "It'd be nice if our service owners could just tag their components somehow to +> let the CD system know to automatically generate SRV records or not for that +> service." + +Labels are mainly intended to be used for filtering of entities, by external +systems that want to find entities that have some certain property. This is +sometimes used for feature detection / selection. An example could be to add a +label `deployments.my-company.net/register-srv: "true"`. + +At the time of writing this, the use of labels is very limited and we are still +settling together with the community on how to best use them. If you feel that +your use case fits the labels best, we would appreciate if you let the Backstage +maintainers know. + +You are free to add labels. This carries no risk or impact to other systems as +long as you abide by the following naming rules. + +- The `backstage.io` label prefix is reserved for use by the Backstage + maintainers. Reach out to us if you feel that you would like to make an + addition to that prefix. +- Labels that pertain to a well known third party system should ideally be + prefixed with a domain, in a way that makes sense to a reader and connects it + clearly to the system (or the maker of the system). For example, you might use + a `pagerduty.com` prefix for pagerduty related labels, but maybe not + `ldap.com` for LDAP labels since it's not directly affiliated with or owned by + an LDAP foundation/company/similar. +- Labels that have no prefix at all, are considered local to your Backstage + instance and can be used freely as such, but you should not make use of them + outside of your organization. For example, if you were to open source a plugin + that generates or consumes labels, then those labels must be properly prefixed + with your company domain or a domain that pertains to the label at hand. + +## Adding a New Relation Type + +Example intents: + +> "We have this concept of service maintainership, separate from ownership, that +> we would like to make relations to individual users for." + +> "We feel that we want to explicitly model the team-to-global-department +> mapping as a relation, because it is core to our org setup and we frequently +> query for it." + +Any processor can emit relations for entities as they are being processed, and +new processors can be added when building the backend catalog using the +`CatalogBuilder`. They can emit relations based on the entity data itself, or +based on information gathered from elsewhere. Relations are directed and go from +a source entity to a target entity. They are also tied to the entity that +originated them - the one that was subject to processing when the relation was +emitted. Relations may be dangling (referencing something that does not actually +exist by that name in the catalog), and callers need to be aware of that. + +There is a set of [well-known relations](well-known-relations.md), but you are +free to emit your own as well. You cannot change the fact that they are directed +and have a source and target that have to be an +[entity reference](references.md), but you can invent your own types. You do not +have to make any changes to the catalog backend in order to accept new relation +types. + +At the time of writing this, we do not have any namespacing/prefixing scheme for +relation types. The type is also not validated to contain only some particular +set of characters. Until rules for this are settled, you should stick to using +only letters, dashes and digits, and to avoid collisions with future core +relation types, you may want to prefix the type somehow. For example: +`myCompany-maintainerOf` + `myCompany-maintainedBy`. + +If you have a suggestion for a relation type to be elevated to the core +offering, reach out to the Backstage maintainers or a support partner. + +## Using a Well-Known Relation Type for a New Purpose + +Example intents: + +> "The ownerOf/ownedBy relation types sound like a good fit for expressing how +> users are technical owners of our company specific ServiceAccount kind, and we +> want to reuse those relation types for that." + +At the time of writing, this is uncharted territory. If the documented use of a +relation states that one end of the relation commonly is a User or a Group, for +example, then consumers are likely to have conditional statements on the form +`if (x.kind === 'User') {} else {}`, which get confused when an unexpected kind +appears. + +If you want to extend the use of an established relation type in a way that has +an effect outside of your organization, reach out to the Backstage maintainers +or a support partner to discuss risk/impact. It may even be that one end of the +relation could be considered for addition to the core. + +## Adding a New Status field + +Example intent: + +> "We would like to convey entity statuses through the catalog in a generic way, +> as an integration layer. Our monitoring and alerting system has a plugin with +> Backstage, and it would be useful if the entity's status field contained the +> current alert state close to the actual entity data for anyone to consume. We +> find the `status.items` semantics a poor fit, so we would prefer to make our +> own custom field under `status` for these purposes." + +We have not yet ventured to define any generic semantics for the `status` +object. We recommend sticking with the `status.items` mechanism where possible +(see below), since third party consumers will not be able to consume your status +information otherwise. Please reach out to the maintainers on Discord or by +making a GitHub issue describing your use case if you are interested in this +topic. + +## Adding a New Status Item Type + +Example intent: + +> "The semantics of the entity `status.items` field are fine for our needs, but +> we want to contribute our own type of status into that array instead of the +> catalog specific one." + +This is a simple, low risk way of adding your own status information to +entities. Consumers will be able to easily track and display the status together +with other types / sources. + +We recommend that any status type that are not strictly private within the +organization be namespaced to avoid collisions. Statuses emitted by Backstage +core processes will for example be prefixed with `backstage.io/`, your +organization may prefix with `my-org.net/`, and `pagerduty.com/active-alerts` +could be a sensible complete status item type for that particular external +system. + +The mechanics for how to emit custom statuses is not in place yet, so if this is +of interest to you, you might consider contacting the maintainers on Discord or +my making a GitHub issue describing your use case. +[This issue](https://github.com/backstage/backstage/issues/2292) also contains +more context. + +## Referencing different environments with the model + +Example intent: + +> "I have multiple versions of my API deployed in different environments so I +> want to have `mytool-dev` and `mytool-prod` as different entities." + +While it's possible to have different versions of the same thing represented as +separate entities, it's something we generally recommend against. We believe +that a developer should be able to just find for example one `Component` +representing a service, and to be able to see the different code versions that +are deployed throughout your stack within its view. This reasoning works +similarly for other kinds as well, such as `API`. + +That being said - sometimes the differences between versions are so large, that +they represent what is for all intents and purposes an entirely new entity as +seen from the consumer's point of view. This can happen for example for +different _significant_ major versions of an API, and in particular if the two +major versions coexist in the ecosystem for some time. In those cases, it can be +motivated to have one `my-api-v2` and one `my-api-v3` named entity. This matches +the end user's expectations when searching for the API, and matches the desire +to maybe have separate documentation for the two and similar. But use this +sparingly - only do it if the extra modelling burden is outweighed by any +potential better clarity for users. + +When writing your custom plugins, we encourage designing them such that they can +show all the different variations through environments etc under one canonical +reference to your software in the catalog. For example for a continuous +deployment plugin, a user is likely to be greatly helped by being able to see +the entity's versions deployed in all different environments next to each other +in one view. That is also where they might be offered the ability to promote +from one environment to the other, do rollbacks, see their relative performance +metrics, and similar. This coherency and collection of tooling in one place is +where something like Backstage can offer the most value and effectiveness of +use. Splitting your entities apart into small islands makes this harder. + +## Implementing custom model extensions + +This section walks you through the steps involved extending the catalog model +with a new Entity type. + +### Creating a custom entity definition + +The first step of introducing a custom entity is to define what shape and schema +it has. We do this using a TypeScript type, as well as a JSONSchema schema. + +Most of the time you will want to have at least the TypeScript type of your +extension available in both frontend and backend code, which means you likely +want to have an isomorphic package that houses these types. Within the Backstage +main repo the package naming pattern of `-common` is used for isomorphic +packages, and you may choose to adopt this pattern as well. + +You can generate an isomorphic plugin package by running:`yarn new --select plugin-common` +or you can run `yarn new` and then select "plugin-common" from the list of options + +There's at this point no existing templates for generating isomorphic plugins +using the `@backstage/cli`. Perhaps the simplest way to get started right now is +to copy the contents of one of the existing packages in the main repository, +such as `plugins/scaffolder-common`, and rename the folder and file contents to +the desired name. This example uses _foobar_ as the plugin name so the plugin +will be named _foobar-common_. + +Once you have a common package in place you can start adding your own entity +definitions. For the exact details on how to do that we defer to getting +inspired by the existing +[scaffolder-common](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-common/src/index.ts) +package. But in short you will need to declare a TypeScript type and a +JSONSchema for the new entity kind. + +### Building a custom processor for the entity + +The next step is to create a custom processor for your new entity kind. This +will be used within the catalog to make sure that it's able to ingest and +validate entities of our new kind. Just like with the definition package, you +can find inspiration in for example the existing +[ScaffolderEntitiesProcessor](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend-module-scaffolder-entity-model/src/processor/ScaffolderEntitiesProcessor.ts). +We also provide a high-level example of what a catalog process for a custom +entity might look like: + +```ts +import { CatalogProcessor, CatalogProcessorEmit, processingResult } from '@backstage/plugin-catalog-node'; +import { LocationSpec } from '@backstage/plugin-catalog-common' +import { Entity, entityKindSchemaValidator } from '@backstage/catalog-model'; + +// For an example of the JSONSchema format and how to use $ref markers to the +// base definitions, see: +// https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json +import { foobarEntityV1alpha1Schema } from '@internal/catalog-model'; + +export class FoobarEntitiesProcessor implements CatalogProcessor { + // You often end up wanting to support multiple versions of your kind as you + // iterate on the definition, so we keep each version inside this array as a + // convenient pattern. + private readonly validators = [ + // This is where we use the JSONSchema that we export from our isomorphic + // package + entityKindSchemaValidator(foobarEntityV1alpha1Schema), + ]; + + // Return processor name + getProcessorName(): string { + return 'FoobarEntitiesProcessor' + } + + // validateEntityKind is responsible for signaling to the catalog processing + // engine that this entity is valid and should therefore be submitted for + // further processing. + async validateEntityKind(entity: Entity): Promise { + for (const validator of this.validators) { + // If the validator throws an exception, the entity will be marked as + // invalid. + if (validator(entity)) { + return true; + } + } + + // Returning false signals that we don't know what this is, passing the + // responsibility to other processors to try to validate it instead. + return false; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + if ( + entity.apiVersion === 'example.com/v1alpha1' && + entity.kind === 'Foobar' + ) { + const foobarEntity = entity as FoobarEntityV1alpha1; + + // Typically you will want to emit any relations associated with the + // entity here. + emit(processingResult.relation({ ... })) + } + + return entity; + } +} +``` + +Once the processor is created it can be wired up to the catalog via the +`CatalogBuilder` in `packages/backend/src/plugins/catalog.ts`: + +```ts title="packages/backend/src/plugins/catalog.ts" +/* highlight-add-next-line */ +import { FoobarEntitiesProcessor } from '@internal/plugin-foobar-backend'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const builder = await CatalogBuilder.create(env); + /* highlight-add-next-line */ + builder.addProcessor(new FoobarEntitiesProcessor()); + const { processingEngine, router } = await builder.build(); + // .. +} +``` diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index b3d7de3a65..51acd44c1d 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -519,6 +519,9 @@ will be used within the catalog to make sure that it's able to ingest and validate entities of our new kind. Just like with the definition package, you can find inspiration in for example the existing [ScaffolderEntitiesProcessor](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend-module-scaffolder-entity-model/src/processor/ScaffolderEntitiesProcessor.ts). + +The custom processor should be created as a separate module for the catalog plugin. For information on how to set that up, see the [plugin docs](../../plugins/backend-plugin.md#creating-a-backend-plugin). Use `yarn new --select backend-module` instead to create a module. For our case, the module ID will be `foobar` and the plugin ID will be `catalog`. + We also provide a high-level example of what a catalog process for a custom entity might look like: @@ -585,20 +588,43 @@ export class FoobarEntitiesProcessor implements CatalogProcessor { } ``` -Once the processor is created it can be wired up to the catalog via the -`CatalogBuilder` in `packages/backend/src/plugins/catalog.ts`: +#### New Backend -```ts title="packages/backend/src/plugins/catalog.ts" +To use your custom processor, you'll need to add the module to your backend as well as integrate your module with the catalog plugin. + +```ts title="plugins/catalog-backend-module-foobar/src/index.ts" +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; /* highlight-add-next-line */ -import { FoobarEntitiesProcessor } from '@internal/plugin-foobar-backend'; +import { FoobarEntitiesProcessor } from './providers'; -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - const builder = await CatalogBuilder.create(env); - /* highlight-add-next-line */ - builder.addProcessor(new FoobarEntitiesProcessor()); - const { processingEngine, router } = await builder.build(); - // .. -} +export const catalogModuleFoobarEntitiesProcessor = createBackendModule({ + pluginId: 'catalog', + moduleId: 'foobar', + register(env) { + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + }, + async init({ catalog }) { + catalog.addProcessor(new FoobarEntitiesProcessor()); + }, + }); + }, +}); + +export default catalogModuleFoobarEntitiesProcessor; ``` + +This module can then be installed to your backend like so, + +```ts +backend.add(import('@internal/plugin-catalog-backend-module-foobar')); +``` + +#### Legacy Backend + +Look through the [legacy documentation](./extending-the-model--old.md). diff --git a/docs/features/software-catalog/life-of-an-entity.md b/docs/features/software-catalog/life-of-an-entity.md index d1c01141e4..13fe496fc1 100644 --- a/docs/features/software-catalog/life-of-an-entity.md +++ b/docs/features/software-catalog/life-of-an-entity.md @@ -162,9 +162,13 @@ steps and merging them into the final object which is what is visible from the catalog API. As the final entity itself gets updated, the stitcher makes sure that the search table gets refreshed accordingly as well. -> Note: The search table mentioned here is not related to the core Search -> feature of Backstage. It's rather the table that backs the ability to filter -> catalog API query results. +:::note Note + +The search table mentioned here is not related to the core Search +feature of Backstage. It's rather the table that backs the ability to filter +catalog API query results. + +::: ![Stitching overview](../../assets/features/catalog/life-of-an-entity_stitching.svg) diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 6396d7530a..083516192c 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -86,12 +86,17 @@ contains more information about the required fields. Once we have a `template.yaml` ready, we can then add it to the software catalog for use by the scaffolder. -> Note: When you add or modify a template, you will need to refresh the location entity. -> Otherwise, Backstage won't display the template in the available templates, -> or it will keep showing the old template. You can refresh the location instance by -> going into `Catalog` web page, choosing `Locations` instead of `Components`, and selecting the correct location entity. -> From there, you can click on the refresh icon representing "Scheduled entity refresh" action. -> Afterwards, you should see your template updated. +:::note Note + +When you add or modify a template, you will need to refresh the location entity. +Otherwise, Backstage won't display the template in the available templates, +or it will keep showing the old template. You can refresh the location instance by +going into `Catalog` web page, choosing `Locations` instead of `Components`, and selecting the correct +location entity. +From there, you can click on the refresh icon representing "Scheduled entity refresh" action. +Afterwards, you should see your template updated. + +::: You can add the template files to the catalog through [static location configuration](../software-catalog/configuration.md#static-location-configuration), diff --git a/docs/features/software-templates/authorizing-parameters-steps-and-actions.md b/docs/features/software-templates/authorizing-parameters-steps-and-actions.md index 072f750a14..105c336538 100644 --- a/docs/features/software-templates/authorizing-parameters-steps-and-actions.md +++ b/docs/features/software-templates/authorizing-parameters-steps-and-actions.md @@ -229,4 +229,8 @@ backend.add(customPermissionBackendModule); /* highlight-add-end */ ``` -> Note: the `ExamplePermissionPolicy` here could be the one from the [Authorizing parameters and steps](#authorizing-parameters-and-steps) example or from the [Authorizing actions](#authorizing-actions) example. It would work the same way for both of them. +:::note Note + +The `ExamplePermissionPolicy` here could be the one from the [Authorizing parameters and steps](#authorizing-parameters-and-steps) example or from the [Authorizing actions](#authorizing-actions) example. It would work the same way for both of them. + +::: diff --git a/docs/features/software-templates/builtin-actions.md b/docs/features/software-templates/builtin-actions.md index 94b2d2298b..5b1e4347da 100644 --- a/docs/features/software-templates/builtin-actions.md +++ b/docs/features/software-templates/builtin-actions.md @@ -57,7 +57,11 @@ backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); backend.start(); ``` -> Note: This is a simplified example of what your backend may look like, you may have more code in here then this. +:::note Note + +This is a simplified example of what your backend may look like, you may have more code in here then this. + +::: ## Listing Actions diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md index 24b8c530b8..571f1150d0 100644 --- a/docs/features/software-templates/configuration.md +++ b/docs/features/software-templates/configuration.md @@ -12,7 +12,11 @@ This is done in your `app-config.yaml` by adding [Backstage integrations](https://backstage.io/docs/integrations/) for the appropriate source code repository for your organization. -> Note: Integrations may already be set up as part of your `app-config.yaml`. +:::note Note + +Integrations may already be set up as part of your `app-config.yaml`. + +::: The next step is to [add templates](http://backstage.io/docs/features/software-templates/adding-templates) to your Backstage app. diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 9642214ede..d4fa6ac971 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -20,10 +20,14 @@ locations like GitHub or GitLab. > Be sure to have covered > [Getting Started with Backstage](../../getting-started) before proceeding. -> Note: if you're running Backstage with Node 20 or later, you'll need to pass the flag `--no-node-snapshot` to Node in order to -> use the templates feature. -> One way to do this is to specify the `NODE_OPTIONS` environment variable before starting Backstage: -> `export NODE_OPTIONS=--no-node-snapshot` +:::note Note + +If you're running Backstage with Node 20 or later, you'll need to pass the flag `--no-node-snapshot` to Node in order to +use the templates feature. +One way to do this is to specify the `NODE_OPTIONS` environment variable before starting Backstage: +`export NODE_OPTIONS=--no-node-snapshot` + +::: The Software Templates are available under `/create`. For local development you should be able to reach them at `http://localhost:3000/create`. diff --git a/docs/features/software-templates/migrating-to-rjsf-v5.md b/docs/features/software-templates/migrating-to-rjsf-v5.md index 829aaf8410..856841bdd4 100644 --- a/docs/features/software-templates/migrating-to-rjsf-v5.md +++ b/docs/features/software-templates/migrating-to-rjsf-v5.md @@ -5,7 +5,11 @@ title: 'Migrating to react-jsonschema-form@v5' description: Docs on migrating to `react-jsonschema-form`@v5 and the new designs --- -> Note: If you were previously using the `/alpha` imports to test out the `scaffolder/next` work, those imports have been promoted to the default exports from the respective packages. You should just have to remove the `/alpha` from the import path, and remove the `Next` from the import name. `NextScaffolderPage` -> `ScaffolderPage`, `createNextScaffolderFieldExtension` -> `createScaffolderFieldExtension` etc. +:::note Note + +If you were previously using the `/alpha` imports to test out the `scaffolder/next` work, those imports have been promoted to the default exports from the respective packages. You should just have to remove the `/alpha` from the import path, and remove the `Next` from the import name. `NextScaffolderPage` -> `ScaffolderPage`, `createNextScaffolderFieldExtension` -> `createScaffolderFieldExtension` etc. + +::: ## What's `react-jsonschema-form`? diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 92ff092948..3b358e4c14 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -8,10 +8,14 @@ If you want to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used alongside our [built-in actions](./builtin-actions.md). -> Note: When adding custom actions, the actions array will **replace the -> built-in actions too**. Meaning, you will no longer be able to use them. -> If you want to continue using the builtin actions, include them in the actions -> array when registering your custom actions, as seen below. +:::note Note + +When adding custom actions, the actions array will **replace the +built-in actions too**. Meaning, you will no longer be able to use them. +If you want to continue using the builtin actions, include them in the actions +array when registering your custom actions, as seen below. + +::: ## Writing your Custom Action diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 2cb8543ccd..8c9c4401cc 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -13,9 +13,14 @@ out-of-the box experience. ![TechDocs Architecture diagram](../../assets/techdocs/architecture-basic.drawio.svg) -> Note: See below for our recommended deployment architecture which takes care -> of stability, scalability and speed. Also look at the -> [HOW TO migrate guide](how-to-guides.md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach). +:::note Note + +See below for our recommended deployment architecture which takes care +of stability, scalability and speed. Also look at the +[HOW TO migrate guide](how-to-guides +md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach). + +::: When you open a TechDocs site in Backstage, the [TechDocs Reader](./concepts.md#techdocs-reader) makes a request to diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index bf55c90af9..6cbbc83614 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -219,7 +219,11 @@ backend.add(import('@backstage/plugin-techdocs-backend/alpha')); backend.start(); ``` -> Note: The above is a very simplified example, you may have more content then this in your version. +:::note Note + +The above is a very simplified example, you may have more content then this in your version. + +::: ## Setting the configuration diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index b25c2a3928..3a7cfd97bb 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -499,8 +499,12 @@ Start writing your documentation by adding more markdown (.md) files to this folder (/docs) or replace the content in this file. ``` -> Note: The values of `site_name`, `component_id` and `site_description` depends -> on how you have configured your `template.yaml` +:::note Note + +The values of `site_name`, `component_id` and `site_description` depends +on how you have configured your `template.yaml`. + +::: Done! You now have support for TechDocs in your own software template! @@ -514,7 +518,11 @@ theme: font: false ``` -> Note: The addition `name: material` is necessary. Otherwise it will not work +:::note Note + +The addition `name: material` is necessary. Otherwise it will not work + +::: ## How to enable iframes in TechDocs @@ -623,12 +631,16 @@ plugins: - kroki ``` -> Note: you will very likely want to set a `kroki` `ServerURL` configuration in your -> `mkdocs.yml` as well. The default value is the publicly hosted `kroki.io`. If -> you have sensitive information in your organization's diagrams, you should set -> up a [server of your own](https://docs.kroki.io/kroki/setup/install/) and use it -> instead. Check out [mkdocs-kroki-plugin config](https://github.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin#config) -> for more plugin configuration details. +:::note Note + +You will very likely want to set a `kroki` `ServerURL` configuration in your +`mkdocs.yml` as well. The default value is the publicly hosted `kroki.io`. If +you have sensitive information in your organization's diagrams, you should set +up a [server of your own](https://docs.kroki.io/kroki/setup/install/) and use it +instead. Check out [mkdocs-kroki-plugin config](https://github.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin#config) +for more plugin configuration details. + +::: 4. **Add mermaid code into TechDocs:** @@ -766,7 +778,11 @@ backend.add(techdocsCustomBuildStrategy()); backend.start(); ``` -> Note: You may need to add the `@backstage/plugin-techdocs-node` package to your backend `package.json` if it's not been imported already. +:::note Note + +You may need to add the `@backstage/plugin-techdocs-node` package to your backend `package.json` if it's not been imported already. + +::: ## How to use other mkdocs plugins? diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index aaf2758241..6ba826ea62 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -169,17 +169,21 @@ permissions to: - `s3:ListBucket` - To retrieve bucket metadata - `s3:GetObject` - To retrieve files from the bucket -> Note: If you need to migrate documentation objects from an older-style path -> format including case-sensitive entity metadata, you will need to add some -> additional permissions to be able to perform the migration, including: -> -> - `s3:PutBucketAcl` (for copying files, -> [more info here](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectAcl.html)) -> - `s3:DeleteObject` and `s3:DeleteObjectVersion` (for deleting migrated files, -> [more info here](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html)) -> -> ...And you will need to ensure the permissions apply to the bucket itself, as -> well as all resources under the bucket. See the example policy below. +:::note Note + +If you need to migrate documentation objects from an older-style path +format including case-sensitive entity metadata, you will need to add some +additional permissions to be able to perform the migration, including: + +- `s3:PutBucketAcl` (for copying files, + [more info here](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectAcl.html)) +- `s3:DeleteObject` and `s3:DeleteObjectVersion` (for deleting migrated files, + [more info here](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html)) + +...And you will need to ensure the permissions apply to the bucket itself, as +well as all resources under the bucket. See the example policy below. + +::: ```json { diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md index b59c4149e0..ddc95b65eb 100644 --- a/docs/integrations/azure/locations.md +++ b/docs/integrations/azure/locations.md @@ -68,11 +68,15 @@ integrations: If you do not specify the `organizations` field the credential will be used for all organizations for which no other credential is configured. -> Note: An Azure DevOps provider is added automatically at startup for -> convenience, so you only need to list it if you want to supply a -> [personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate), -> a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity), -> or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity) +:::note Note + +An Azure DevOps provider is added automatically at startup for +convenience, so you only need to list it if you want to supply a +[personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate), +a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity), +or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity) + +::: The configuration is a structure with these elements: @@ -86,9 +90,11 @@ The `credentials` element is a structure with these elements: - `tenantId`: The tenant ID of the service principal (required for service principal) - `personalAccessToken`: The personal access token (required for personal access token) -> Note: -> -> - You cannot use a service principal or managed identity for Azure DevOps Server (on-premises) organizations -> - You can only use a service principal or managed identity for Microsoft Entra ID (formerly Azure Active Directory) backed Azure DevOps organizations -> - You can only specify one credential per host without any organizations specified -> - The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API +:::note Note + +- You cannot use a service principal or managed identity for Azure DevOps Server (on-premises) organizations +- You can only use a service principal or managed identity for Microsoft Entra ID (formerly Azure Active Directory) backed Azure DevOps organizations +- You can only specify one credential per host without any organizations specified +- The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API + +::: diff --git a/docs/integrations/bitbucketCloud/locations.md b/docs/integrations/bitbucketCloud/locations.md index e7666efb54..9b08a79c63 100644 --- a/docs/integrations/bitbucketCloud/locations.md +++ b/docs/integrations/bitbucketCloud/locations.md @@ -22,10 +22,18 @@ integrations: appPassword: ${BITBUCKET_CLOUD_PASSWORD} ``` -> Note: A public Bitbucket Cloud provider is added automatically at startup for -> convenience, so you only need to list it if you want to supply credentials. +:::note Note -> Note: The credential used for this is type [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). An Atlassian Account API key will not work +A public Bitbucket Cloud provider is added automatically at startup for +convenience, so you only need to list it if you want to supply credentials. + +::: + +:::note Note + +The credential used for this is type [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). An Atlassian Account API key will not work. + +::: Directly under the `bitbucketCloud` key is a list of provider configurations, where you can list the Bitbucket Cloud providers you want to fetch data from. diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index f16d1c17b2..920cea9297 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -130,8 +130,12 @@ catalog: This provider supports multiple organizations via unique provider IDs. -> **Note:** It is possible but certainly not recommended to skip the provider ID level. -> If you do so, `default` will be used as provider ID. +:::note Note + +It is possible but certainly not recommended to skip the provider ID level. +If you do so, `default` will be used as provider ID. + +::: - **`catalogPath`** _(optional)_: Default: `/catalog-info.yaml`. diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md index 06b4e07daa..f94ffe6c7a 100644 --- a/docs/integrations/github/org.md +++ b/docs/integrations/github/org.md @@ -17,9 +17,13 @@ is a hierarchy of [`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind entities that mirror your org setup. -> Note: This adds `User` and `Group` entities to the catalog, but does not -> provide authentication. See the -> [GitHub auth provider](../../auth/github/provider.md) for that. +:::note Note + +This adds `User` and `Group` entities to the catalog, but does not +provide authentication. See the +[GitHub auth provider](../../auth/github/provider.md) for that. + +::: ## Permissions diff --git a/docs/integrations/gitlab/discovery.md b/docs/integrations/gitlab/discovery.md index 91efe05fee..b16f6db839 100644 --- a/docs/integrations/gitlab/discovery.md +++ b/docs/integrations/gitlab/discovery.md @@ -136,7 +136,11 @@ To use the discovery provider, you'll need a GitLab integration [set up](locations.md) with a `token`. Then you can add a provider config per group to the catalog configuration. -> > NOTE: if you are using the New Backend System, the `schedule` has to be setup in the config, as shown below. +:::note Note + +If you are using the New Backend System, the `schedule` has to be setup in the config, as shown below. + +::: ```yaml title="app-config.yaml" catalog: diff --git a/docs/integrations/gitlab/org.md b/docs/integrations/gitlab/org.md index 14587461e5..2cc0dcfb82 100644 --- a/docs/integrations/gitlab/org.md +++ b/docs/integrations/gitlab/org.md @@ -158,7 +158,11 @@ amount of data, this can take significant time and resources. The token used must have the `read_api` scope, and the Users and Groups fetched will be those visible to the account which provisioned the token. -> > NOTE: if you are using the New Backend System, the `schedule` has to be setup in the config, as shown below. +:::note Note + +If you are using the New Backend System, the `schedule` has to be setup in the config, as shown below. + +::: ```yaml catalog: diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 49a2776fb1..a10bc3918c 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -29,8 +29,12 @@ to `@backstage/plugin-catalog-backend-module-ldap` to your backend package. yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap ``` -> Note: When configuring to use a Provider instead of a Processor you do not -> need to add a _location_ pointing to your LDAP server +:::note Note + +When configuring to use a Provider instead of a Processor you do not +need to add a _location_ pointing to your LDAP server + +::: Update the catalog plugin initialization in your backend to add the provider and schedule it: diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 186619d1df..1a41eedd63 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -6,91 +6,87 @@ description: Roadmap of Backstage ## The Backstage Roadmap -Backstage is currently under rapid development. This page details the project's -public roadmap, the result of ongoing collaboration between the core maintainers -and the broader Backstage community. +Backstage is still under rapid development, and this page details the project's +public roadmap. This not a complete list of all work happening in and around the +project, it only highlights the highest priority initiatives worked on by the +core maintainers. -The Backstage roadmap lays out both [“what's next”](#whats-next) and ["future work"](#future-work). With "next" we mean features planned for release within -the ongoing quarter from July through September 2022. With "future" we mean -features on the radar, but not yet scheduled. +## 2024 Fall Roadmap -| [What's next](#whats-next) | [Future work](#future-work) | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| [Backend Services (MVP)](#backend-services-mvp)
[Backstage Security Audit](#backstage-security-audit)
[Backstage Threat Model](#backstage-threat-model)
[Software Catalog pagination](#software-catalog-pagination)
[More SIGs](#more-sigs) | Ease of onboarding
Composable Homepage 1.0
Creator experience
GraphQL
Telemetry | +The initiatives listed below are planned for release within the next half-year, starting in May 2024. The roadmap is updated every 6 months, and the next update is planned for November 2024. -The long-term roadmap (12 - 36 months) is not detailed in the public roadmap. -Third-party contributions are also not currently included in the roadmap. Let us -know about any ongoing developments and we're happy to include them here as -well. +### Backend System 1.0 -## What's next +The goal of this initiative is the stable 1.0 release of the [new backend system](../backend-system/index.md). +This includes ensuring that all documentation is up to date, and includes API +reviews and refactoring efforts to ensure that what is released is both stable +and evolvable. You can follow along with this work in the [meta issue](https://github.com/backstage/backstage/issues/24493). -The feature set below is planned for the ongoing quarter, and grouped by theme. -The list order doesn't necessarily reflect priority, and the development/release -cycle will vary based on maintainer schedules. +As part of this initiative, there will also be an exploration on how to +simplify extension of backend services. It is not currently possible to augment +backend services through declarative integration, they are instead only +customizable through complete replacement. This also limits the ability to +modularize services and scale ownership of the implementations. The goal is to +provide a more flexible and scalable way to extend backend services. -### Backend Services (MVP) +### New Frontend System - Ready for Adoption -To better scale and maintain the Backstage instances, a backend services system -is planned to be introduced as part of the software architecture. This layer of -backend services will help in decoupling the various modules (e.g. Catalog and -Scaffolder) from the frontend experience. +The [new fronted system](../frontend-system/index.md) still needs more work, and +the next milestone is to improve it to the point where there is enough +confidence in the design to start encouraging adoption in the community. You can +follow along with this work in the [meta issue](https://github.com/backstage/backstage/issues/19545). +This milestone also includes reaching and executing [rollout phase 2](https://github.com/backstage/backstage/issues/19545#issuecomment-1766069146). -After the experimentation and design happened in the past quarter, soon we plan to release a first version to start providing the first benefits to adopters and developers. +Once the initial milestone is reached, the goal is to also build out broader +support for the new frontend system in the core plugins. ### Backstage Security Audit -This is the continuation of the initiative started in the previous quarters. This -quarter will see the publication of the report describing the outcome of the -audit, together the first fixes and the development of some of the changes -required to address the vulnerabilities. +This is the second security audit of the Backstage project. It is done together, +and with the support of the [Cloud Native Computing Foundation (CNCF)](https://www.cncf.io/). +This time the audit will in particular focus on the recently introduced +[authentication system](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution), +but also cover other parts of the project. -This initiative is the first of a broader Security Strategy for Backstage. The -purpose of the Security Audit is to involve third-party companies in auditing -the platform. The benefit for the adopters is clear: we want Backstage to be as -secure as possible, and we want to make it reliable through a specific -initiative. +### Plugin Metadata -This initiative is done together with, and with the support of, the [Cloud Native Computing Foundation (CNCF)](https://www.cncf.io/). +The goal of this initiative is to provide better machine readable metadata for +Backstage packages, available both at runtime, at build-time and as part of +package registries. We want to surface information such as what packages make up +a particular plugin, what features it provides, and more generally laying the +foundation for an evolvable plugin metadata system. -### Backstage Threat Model +### MUI v5 Green-light -This is another (relevant) initiative planned to make Backstage a secure product for the adopters. The goals of this initiative are: +Material-UI v4 is still the officially supported version of MUI in Backstage. +While we have heard that adopters have had success using MUI 5, this is still an +untested path with known bugs. The goal of this initiative is to iron out any +remaining issues or gaps, and then provide a green light for migration to MUI 5. -1. Understand where security investment and attention is needed. -2. Guide the upcoming security audit. -3. Communicate expectations to Backstage adopters and inform and attract security researchers. +### Configuration Improvements -The planned artifacts are: +This initiative aims to improve the configuration experience and reliability in +Backstage. Areas for improvement include the way that configuration schema is +loaded, the way that plugins access configuration that is not owned by them, how +plugins read configuration, and how configuration visibility is handled. -- Concise high level threat model that will be included as part of the Backstage security documentation. -- Granular threat model created in conjunction with the security audit to inform further security investment areas for Backstage. +### Versioned Documentation -### Software Catalog pagination +The goal of this initiative is to provide versioned documentation at +[backstage.io](https://backstage.io). This lets us provide documentation that is +both up-to-date while at the same time not ahead of the latest release. -Today adopters with a big catalog (with several thousands of software components) might not have an ideal end-user experience when viewing the `/catalog` page. The issue is related to how the entities are fetched by the frontend. In order to provide a better end-user experience the pagination of the catalog’s entities needs to be enforced. Some experimentation is already completed but in this quarter we plan to continue, and hopefully complete, this relevant enhancement. +### Rework Pull Request & Issue Process -### More SIGs +Our current review and issue triage process is centered around either core- or +project area maintainers. The goal of this initiative is to make it simpler for +more members of the community to be involved and contribute to this process. -In the last quarter we launched the [Catalog SIG (Special Interest Group)](https://github.com/backstage/community/tree/main/sigs/sig-catalog) to better coordinate the increasing number of contributions to the project. We think that this is the proper path to follow to engage more with the contributors. For this reason we will launch other SIGs dedicated to the most interesting topics for the community. +### Catalog Observability -## Future work - -The following feature list doesn't represent a commitment to develop, and the -list order doesn't reflect any priority or importance, but these features are on -the maintainers' radar, with clear interest expressed by the community. - -- **Ease of onboarding:** A faster (with less development) and easier setup of - Backstage and the most relevant/adopted plugins. -- **Composable Homepage 1.0:** Driving this to 1.0 by adding some composable - components. -- **Creator experience:** Provide a better Backstage user experience through - visual guidelines and templates, especially navigation across plug-ins and - portal functionalities. -- **[GraphQL](https://graphql.org/) support:** Introduce the ability to query - Backstage backend services with a standard query language for APIs. -- **Telemetry:** To efficiently generate logging and metrics in such a way that - adopters can get insights so that Backstage can be monitored and improved. +The goal of this initiative is to provide better tools for debugging catalog +ingestion issues and to more generally reduce friction for setting up and +maintaining the software catalog. ## How to influence the roadmap diff --git a/docs/permissions/getting-started.md b/docs/permissions/getting-started.md index e241f0e38e..4dabf3fcbd 100644 --- a/docs/permissions/getting-started.md +++ b/docs/permissions/getting-started.md @@ -8,7 +8,11 @@ If you prefer to watch a video instead, you can start with this video introducti -> Note: This video was recorded in the January 2022 Contributors Session using `@backstage/create-app@0.4.14`. Some aspects of the demo may have changed in later releases. +:::note Note + +This video was recorded in the January 2022 Contributors Session using `@backstage/create-app@0.4.14`. Some aspects of the demo may have changed in later releases. + +::: Backstage integrators control permissions by writing a policy. In general terms, a policy is simply an async function which receives a request to authorize a specific action for a user and (optional) resource, and returns a decision on whether to authorize that permission. Integrators can implement their own policies from scratch, or adopt reusable policies written by others. diff --git a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md index a9e62040c5..84e86140fc 100644 --- a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md +++ b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md @@ -36,7 +36,11 @@ This approach will work for simple cases, but it has a downside: it forces us to To avoid this situation, the permissions framework has support for filtering items in the data source itself. In this part of the tutorial, we'll describe the steps required to use that behavior. -> Note: in order to perform authorization filtering in this way, the data source must allow filters to be logically combined with AND, OR, and NOT operators. The conditional decisions returned by the permissions framework use a [nested object](https://backstage.io/docs/reference/plugin-permission-common.permissioncriteria) to combine conditions. If you're implementing a filter API from scratch, we recommend using the same shape for ease of interoperability. If not, you'll need to implement a function which transforms the nested object into your own format. +:::note Note + +In order to perform authorization filtering in this way, the data source must allow filters to be logically combined with AND, OR, and NOT operators. The conditional decisions returned by the permissions framework use a [nested object](https://backstage.io/docs/reference/plugin-permission-common.permissioncriteria) to combine conditions. If you're implementing a filter API from scratch, we recommend using the same shape for ease of interoperability. If not, you'll need to implement a function which transforms the nested object into your own format. + +::: ## Creating the read permission diff --git a/docs/permissions/plugin-authors/05-frontend-authorization.md b/docs/permissions/plugin-authors/05-frontend-authorization.md index 92d855698f..60458aaf4d 100644 --- a/docs/permissions/plugin-authors/05-frontend-authorization.md +++ b/docs/permissions/plugin-authors/05-frontend-authorization.md @@ -8,7 +8,11 @@ In the previous sections, we learned how to protect our plugin's backend API rou Take, for example, the "Add" button in our todo list application. When a user clicks this button, the frontend makes a `POST` request to the `/todos` route of our backend. If a user tries to add a todo but is not authorized, they will have no way of knowing this until they perform the action and are faced with an error. This is a poor user experience. We can do better by disabling the add button. -> Note: Placing frontend components behind authorization cannot take the place of placing your backend routes behind authorization. Authorization checks on the frontend should be used in _addition_ to the corresponding backend authorization, as an improvement to the user experience. If you do not place your backend route behind authorization, a malicious actor can still send a request to the route even if you disabled the corresponding frontend component. +:::note Note + +Placing frontend components behind authorization cannot take the place of placing your backend routes behind authorization. Authorization checks on the frontend should be used in _addition_ to the corresponding backend authorization, as an improvement to the user experience. If you do not place your backend route behind authorization, a malicious actor can still send a request to the route even if you disabled the corresponding frontend component. + +::: ## Using `usePermission` diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index bff38fea40..0b64c6eb37 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -28,12 +28,7 @@ backend.add(import('@backstage/plugin-proxy-backend/alpha')); In `packages/backend/src/index.ts`: ```ts -const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); - -const service = createServiceBuilder(module) - .loadConfig(configReader) - /** ... other routers ... */ - .addRouter('/proxy', await proxy(proxyEnv)); +backend.add(import('@backstage/plugin-proxy-backend/alpha')); ``` ## Configuration @@ -50,6 +45,7 @@ proxy: /simple-example: http://simple.example.com:8080 '/larger-example/v1': target: http://larger.example.com:8080/svc.v1 + credentials: require headers: Authorization: ${EXAMPLE_AUTH_HEADER} # ...or interpolating a value into part of a string, @@ -66,6 +62,23 @@ backend requests to `/api/proxy/simple-example/...` and The value inside each route is either a simple URL string, or an object on the format accepted by [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). +Additionally, it has an optional `credentials` key which can have the following +values: + +- `require`: Callers must provide Backstage user or service credentials with + each request. The credentials are not forwarded to the proxy target. This is + the default. +- `forward`: Callers must provide Backstage user or service credentials with + each request, and those credentials are forwarded to the proxy target. +- `dangerously-allow-unauthenticated`: No Backstage credentials are required to + access this proxy target. The target can still apply its own credentials + checks, but the proxy will not help block non-Backstage-blessed callers. If + you also add `allowedHeaders: ['Authorization']` to an endpoint configuration, + then the Backstage token (if provided) WILL be forwarded. + +Note that if you have `backend.auth.dangerouslyDisableDefaultAuthPolicy` set to +`true`, the `credentials` value does not apply; the proxy will behave as if all +endpoints were set to `dangerously-allow-unauthenticated`. If the value is a string, it is assumed to correspond to: @@ -74,6 +87,7 @@ target: changeOrigin: true pathRewrite: '^/': '/' +credentials: require ``` When the target is an object, it is given verbatim to `http-proxy-middleware` @@ -86,6 +100,7 @@ except with the following caveats for convenience: `'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to `/api/proxy/larger-example/v1/some/path` will be translated to a request to `http://larger.example.com:8080/svc.v1/some/path`. +- If `credentials` is not specified, it is set to `require`. There are also additional settings: diff --git a/docs/tutorials/setup-opentelemetry.md b/docs/tutorials/setup-opentelemetry.md index c49eef26a2..30dde79db8 100644 --- a/docs/tutorials/setup-opentelemetry.md +++ b/docs/tutorials/setup-opentelemetry.md @@ -65,13 +65,19 @@ You can now start your Backstage instance as usual, using `yarn dev`. ## Production Setup -In your `Dockerfile` add the `--require` flag which points to the `instrumentation.js` file +In your `Dockerfile`, copy `instrumentation.js` file into the root of the working directory. + +```Dockerfile +COPY --chown=${NOT_ROOT_USER}:${NOT_ROOT_USER} packages/backend/src/instrumentation.js ./ +``` + +And then add the `--require` flag that points to the file to the CMD array. ```Dockerfile // highlight-remove-next-line CMD ["node", "packages/backend", "--config", "app-config.yaml"] // highlight-add-next-line -CMD ["node", "--require", "./src/instrumentation.js", "packages/backend", "--config", "app-config.yaml"] +CMD ["node", "--require", "./instrumentation.js", "packages/backend", "--config", "app-config.yaml"] ``` If you need to disable/configure some OpenTelemetry feature there are lots of [environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) which you can tweak. diff --git a/microsite/data/plugins/apollo-explorer.yaml b/microsite/data/plugins/apollo-explorer.yaml index cebf36654e..2b0f834f92 100644 --- a/microsite/data/plugins/apollo-explorer.yaml +++ b/microsite/data/plugins/apollo-explorer.yaml @@ -1,7 +1,7 @@ --- title: Apollo Explorer -author: unredundant -authorUrl: https://github.com/unredundant +author: brizzbuzz +authorUrl: https://github.com/brizzbuzz category: Debugging description: Integrates Apollo Explorer graphs as a tool to browse GraphQL API endpoints inside Backstage. documentation: https://github.com/backstage/community-plugins/blob/main/workspaces/apollo-explorer/plugins/apollo-explorer/README.md diff --git a/microsite/data/plugins/ibm-apic-backend.yaml b/microsite/data/plugins/ibm-apic-backend.yaml index 9228524a2d..dcb41a1cef 100644 --- a/microsite/data/plugins/ibm-apic-backend.yaml +++ b/microsite/data/plugins/ibm-apic-backend.yaml @@ -5,7 +5,7 @@ authorUrl: https://croz.net/?utm_source=backstage.io&utm_medium=marketplace&utm_ category: API Management description: Bring IBM APIC to Backstage. documentation: https://github.com/croz-ltd/apic-backend-plugin?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=backstage-ibm-apic-backend -iconUrl: https://croz.net/wp-content/uploads/2024/02/croz_large-o.png +iconUrl: https://croz.net/app/uploads/2024/05/apple-touch-icon.png npmPackageName: '@croz/plugin-ibm-apic-backend' tags: - openapic diff --git a/microsite/docusaurus.config.ts b/microsite/docusaurus.config.ts index b7b98bf9e5..e697c5024f 100644 --- a/microsite/docusaurus.config.ts +++ b/microsite/docusaurus.config.ts @@ -181,6 +181,7 @@ const config: Config = { hideIcon: true, customFont: true, buttonStyle: 'dark', + ratingMode: 'stars', }, ], ], diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 5720c1c60f..49965f4c46 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -65,7 +65,7 @@ export interface Backend { stop(): Promise; } -// @public (undocumented) +// @public @deprecated (undocumented) export const cacheServiceFactory: () => ServiceFactory; // @public (undocumented) @@ -100,7 +100,7 @@ export interface CreateSpecializedBackendOptions { defaultServiceFactories: ServiceFactoryOrFunction[]; } -// @public (undocumented) +// @public @deprecated (undocumented) export const databaseServiceFactory: () => ServiceFactory< PluginDatabaseManager, 'plugin' @@ -121,7 +121,7 @@ export interface DefaultRootHttpRouterOptions { indexPath?: string | false; } -// @public (undocumented) +// @public @deprecated (undocumented) export const discoveryServiceFactory: () => ServiceFactory< DiscoveryService, 'plugin' @@ -137,7 +137,7 @@ export interface ExtendedHttpServer extends http.Server { stop(): Promise; } -// @public +// @public @deprecated export class HostDiscovery implements DiscoveryService { static fromConfig( config: Config, @@ -190,13 +190,13 @@ export type HttpServerOptions = { }; }; -// @public +// @public @deprecated export type IdentityFactoryOptions = { issuer?: string; algorithms?: string[]; }; -// @public (undocumented) +// @public @deprecated (undocumented) export const identityServiceFactory: ( options?: IdentityFactoryOptions | undefined, ) => ServiceFactory; @@ -208,7 +208,7 @@ export interface LifecycleMiddlewareOptions { startupRequestPauseTimeout?: HumanDuration; } -// @public +// @public @deprecated export const lifecycleServiceFactory: () => ServiceFactory< LifecycleService, 'plugin' @@ -255,7 +255,7 @@ export interface MiddlewareFactoryOptions { logger: LoggerService; } -// @public (undocumented) +// @public @deprecated (undocumented) export const permissionsServiceFactory: () => ServiceFactory< PermissionsService, 'plugin' @@ -270,7 +270,7 @@ export function readHelmetOptions(config?: Config): HelmetOptions; // @public export function readHttpServerOptions(config?: Config): HttpServerOptions; -// @public (undocumented) +// @public @deprecated (undocumented) export interface RootConfigFactoryOptions { argv?: string[]; remote?: Pick; @@ -278,7 +278,7 @@ export interface RootConfigFactoryOptions { watch?: boolean; } -// @public (undocumented) +// @public @deprecated (undocumented) export const rootConfigServiceFactory: ( options?: RootConfigFactoryOptions | undefined, ) => ServiceFactory; @@ -314,7 +314,7 @@ export const rootHttpRouterServiceFactory: ( options?: RootHttpRouterFactoryOptions | undefined, ) => ServiceFactory; -// @public +// @public @deprecated export const rootLifecycleServiceFactory: () => ServiceFactory< RootLifecycleService, 'root' @@ -326,19 +326,19 @@ export const rootLoggerServiceFactory: () => ServiceFactory< 'root' >; -// @public (undocumented) +// @public @deprecated (undocumented) export const schedulerServiceFactory: () => ServiceFactory< SchedulerService, 'plugin' >; -// @public (undocumented) +// @public @deprecated (undocumented) export const tokenManagerServiceFactory: () => ServiceFactory< TokenManagerService, 'plugin' >; -// @public (undocumented) +// @public @deprecated (undocumented) export const urlReaderServiceFactory: () => ServiceFactory; // @public (undocumented) diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 0634c30de1..414a4333e7 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -83,6 +83,7 @@ "path-to-regexp": "^6.2.1", "selfsigned": "^2.0.0", "stoppable": "^1.1.0", + "triple-beam": "^1.4.1", "uuid": "^9.0.0", "winston": "^3.2.1", "winston-transport": "^4.5.0" diff --git a/packages/backend-app-api/src/logging/WinstonLogger.test.ts b/packages/backend-app-api/src/logging/WinstonLogger.test.ts index 06f9cdd4c3..0f9e079943 100644 --- a/packages/backend-app-api/src/logging/WinstonLogger.test.ts +++ b/packages/backend-app-api/src/logging/WinstonLogger.test.ts @@ -14,12 +14,10 @@ * limitations under the License. */ -import { TransformableInfo } from 'logform'; +import { format } from 'logform'; import { WinstonLogger } from './WinstonLogger'; - -function msg(info: TransformableInfo): TransformableInfo { - return { message: info.message, level: info.level, stack: info.stack }; -} +import Transport from 'winston-transport'; +import { MESSAGE } from 'triple-beam'; describe('WinstonLogger', () => { it('creates a winston logger instance with default options', () => { @@ -33,57 +31,66 @@ describe('WinstonLogger', () => { expect(childLogger).toBeInstanceOf(WinstonLogger); }); - it('redacter should redact and escape regex', () => { - const redacter = WinstonLogger.redacter(); - const log = { - level: 'error', - message: 'hello (world)', - stack: 'hello (world) from this file', - }; - expect(redacter.format.transform(msg(log))).toEqual(msg(log)); - redacter.add(['hello\n']); - expect(redacter.format.transform(msg(log))).toEqual( - msg({ - ...log, - message: '[REDACTED] (world)', - stack: '[REDACTED] (world) from this file', - }), - ); - redacter.add(['(world']); - expect(redacter.format.transform(msg(log))).toEqual( - msg({ - ...log, - message: '[REDACTED] [REDACTED])', - stack: '[REDACTED] [REDACTED]) from this file', + it('should redact and escape regex', () => { + const mockTransport = new Transport({ + log: jest.fn(), + logv: jest.fn(), + }); + + const logger = WinstonLogger.create({ + format: format.json(), + transports: [mockTransport], + }); + + logger.addRedactions(['hello (world']); + + logger.error('hello (world) from this file'); + + expect(mockTransport.log).toHaveBeenCalledWith( + expect.objectContaining({ + [MESSAGE]: JSON.stringify({ + level: 'error', + message: '***) from this file', + }), }), + expect.any(Function), ); }); - it('redacter should redact nested object', () => { - const redacter = WinstonLogger.redacter(); - const log = { - level: 'error', - message: { - nested: 'hello (world) from nested object', - null: null, - nullProto: Object.create(null, { - foo: { value: 'hello foo', enumerable: true }, - }), - }, - }; + it('should redact nested object', () => { + const mockTransport = new Transport({ + log: jest.fn(), + logv: jest.fn(), + }); - redacter.add(['hello']); - expect(redacter.format.transform(msg(log))).toEqual( - msg({ - ...log, - message: { - nested: '[REDACTED] (world) from nested object', + const logger = WinstonLogger.create({ + format: format.json(), + transports: [mockTransport], + }); + + logger.addRedactions(['hello']); + + logger.error('something went wrong', { + null: null, + nested: 'hello (world) from nested object', + nullProto: Object.create(null, { + foo: { value: 'hello foo', enumerable: true }, + }), + }); + + expect(mockTransport.log).toHaveBeenCalledWith( + expect.objectContaining({ + [MESSAGE]: JSON.stringify({ + level: 'error', + message: 'something went wrong', + nested: '*** (world) from nested object', null: null, nullProto: { - foo: 'hello foo', // read only prop is not redacted + foo: '*** foo', }, - }, + }), }), + expect.any(Function), ); }); }); diff --git a/packages/backend-app-api/src/logging/WinstonLogger.ts b/packages/backend-app-api/src/logging/WinstonLogger.ts index 3fcb4142b6..64e97b230c 100644 --- a/packages/backend-app-api/src/logging/WinstonLogger.ts +++ b/packages/backend-app-api/src/logging/WinstonLogger.ts @@ -27,6 +27,7 @@ import { transports, transport as Transport, } from 'winston'; +import { MESSAGE } from 'triple-beam'; import { escapeRegExp } from '../lib/escapeRegExp'; /** @@ -61,8 +62,8 @@ export class WinstonLogger implements RootLoggerService { let logger = createLogger({ level: process.env.LOG_LEVEL || options.level || 'info', format: format.combine( - redacter.format, options.format ?? defaultFormatter, + redacter.format, ), transports: options.transports ?? new transports.Console(), }); @@ -85,24 +86,16 @@ export class WinstonLogger implements RootLoggerService { let redactionPattern: RegExp | undefined = undefined; - const replace = (obj: TransformableInfo) => { - for (const key in obj) { - if (Object.hasOwn(obj, key)) { - if (typeof obj[key] === 'object') { - obj[key] = replace(obj[key] as TransformableInfo); - } else if (typeof obj[key] === 'string') { - try { - obj[key] = obj[key]?.replace(redactionPattern, '[REDACTED]'); - } catch { - /* ignore read only properties */ - } - } - } - } - return obj; - }; return { - format: format(replace)(), + format: format((obj: TransformableInfo) => { + if (!redactionPattern || !obj) { + return obj; + } + + obj[MESSAGE] = obj[MESSAGE]?.replace?.(redactionPattern, '***'); + + return obj; + })(), add(newRedactions) { let added = 0; for (const redactionToTrim of newRedactions) { diff --git a/packages/backend-app-api/src/services/implementations/cache/cacheServiceFactory.ts b/packages/backend-app-api/src/services/implementations/cache/cacheServiceFactory.ts index cb4e04d574..9b5e5ab16a 100644 --- a/packages/backend-app-api/src/services/implementations/cache/cacheServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/cache/cacheServiceFactory.ts @@ -20,7 +20,10 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/cache` instead. + */ export const cacheServiceFactory = createServiceFactory({ service: coreServices.cache, deps: { diff --git a/packages/backend-app-api/src/services/implementations/config/rootConfigServiceFactory.ts b/packages/backend-app-api/src/services/implementations/config/rootConfigServiceFactory.ts index 68103842bb..c74474e629 100644 --- a/packages/backend-app-api/src/services/implementations/config/rootConfigServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/config/rootConfigServiceFactory.ts @@ -23,7 +23,10 @@ import { RemoteConfigSourceOptions, } from '@backstage/config-loader'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/rootConfig` instead. + */ export interface RootConfigFactoryOptions { /** * Process arguments to use instead of the default `process.argv()`. @@ -37,7 +40,10 @@ export interface RootConfigFactoryOptions { watch?: boolean; } -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/rootConfig` instead. + */ export const rootConfigServiceFactory = createServiceFactory( (options?: RootConfigFactoryOptions) => ({ service: coreServices.rootConfig, diff --git a/packages/backend-app-api/src/services/implementations/database/databaseServiceFactory.ts b/packages/backend-app-api/src/services/implementations/database/databaseServiceFactory.ts index 139609b6c1..972d8dd4ec 100644 --- a/packages/backend-app-api/src/services/implementations/database/databaseServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/database/databaseServiceFactory.ts @@ -21,7 +21,10 @@ import { } from '@backstage/backend-plugin-api'; import { ConfigReader } from '@backstage/config'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/database` instead. + */ export const databaseServiceFactory = createServiceFactory({ service: coreServices.database, deps: { diff --git a/packages/backend-app-api/src/services/implementations/deprecated.ts b/packages/backend-app-api/src/services/implementations/deprecated.ts new file mode 100644 index 0000000000..6f4f76e679 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/deprecated.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './scheduler'; diff --git a/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts b/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts index 180c8706d5..d337da997a 100644 --- a/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts +++ b/packages/backend-app-api/src/services/implementations/discovery/HostDiscovery.ts @@ -29,6 +29,7 @@ type Target = string | { internal: string; external: string }; * resolved to the same host, so there won't be any balancing of internal traffic. * * @public + * @deprecated Please import from `@backstage/backend-defaults/discovery` instead. */ export class HostDiscovery implements DiscoveryService { /** diff --git a/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts b/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts index bfc5a6a489..b29a589438 100644 --- a/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/discovery/discoveryServiceFactory.ts @@ -20,7 +20,10 @@ import { } from '@backstage/backend-plugin-api'; import { HostDiscovery } from './HostDiscovery'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/discovery` instead. + */ export const discoveryServiceFactory = createServiceFactory({ service: coreServices.discovery, deps: { diff --git a/packages/backend-app-api/src/services/implementations/identity/identityServiceFactory.ts b/packages/backend-app-api/src/services/implementations/identity/identityServiceFactory.ts index 58da1c37d5..c2f1a61007 100644 --- a/packages/backend-app-api/src/services/implementations/identity/identityServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/identity/identityServiceFactory.ts @@ -24,16 +24,22 @@ import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; * An identity client options object which allows extra configurations * * @public + * @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead */ export type IdentityFactoryOptions = { issuer?: string; - /** JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256. - * More info on supported algorithms: https://github.com/panva/jose */ + /** + * JWS "alg" (Algorithm) Header Parameter values. Defaults to an array containing just ES256. + * More info on supported algorithms: https://github.com/panva/jose + */ algorithms?: string[]; }; -/** @public */ +/** + * @public + * @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead + */ export const identityServiceFactory = createServiceFactory( (options?: IdentityFactoryOptions) => ({ service: coreServices.identity, diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index a1114ab3e3..e6656801a9 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -28,7 +28,8 @@ export * from './permissions'; export * from './rootHttpRouter'; export * from './rootLifecycle'; export * from './rootLogger'; -export * from './scheduler'; export * from './tokenManager'; export * from './urlReader'; export * from './userInfo'; + +export * from './deprecated'; diff --git a/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts b/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts index 2b68a81807..b3b0135a7c 100644 --- a/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/lifecycle/lifecycleServiceFactory.ts @@ -26,7 +26,10 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; -/** @internal */ +/** + * @internal + * @deprecated + */ export class BackendPluginLifecycleImpl implements LifecycleService { constructor( private readonly logger: LoggerService, @@ -85,7 +88,9 @@ export class BackendPluginLifecycleImpl implements LifecycleService { /** * Allows plugins to register shutdown hooks that are run when the process is about to exit. + * * @public + * @deprecated Please import from `@backstage/backend-defaults/lifecycle` instead. */ export const lifecycleServiceFactory = createServiceFactory({ service: coreServices.lifecycle, diff --git a/packages/backend-app-api/src/services/implementations/permissions/permissionsServiceFactory.ts b/packages/backend-app-api/src/services/implementations/permissions/permissionsServiceFactory.ts index 9824eb2145..c8fa0e7bbf 100644 --- a/packages/backend-app-api/src/services/implementations/permissions/permissionsServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/permissions/permissionsServiceFactory.ts @@ -20,7 +20,10 @@ import { } from '@backstage/backend-plugin-api'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/permissions` instead. + */ export const permissionsServiceFactory = createServiceFactory({ service: coreServices.permissions, deps: { diff --git a/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts b/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts index 197f99b97a..bf5dc09b80 100644 --- a/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/rootLifecycle/rootLifecycleServiceFactory.ts @@ -25,7 +25,10 @@ import { LoggerService, } from '@backstage/backend-plugin-api'; -/** @internal */ +/** + * @internal + * @deprecated + */ export class BackendLifecycleImpl implements RootLifecycleService { constructor(private readonly logger: LoggerService) {} @@ -108,6 +111,7 @@ export class BackendLifecycleImpl implements RootLifecycleService { * Allows plugins to register shutdown hooks that are run when the process is about to exit. * * @public + * @deprecated Please import from `@backstage/backend-defaults/rootLifecycle` instead. */ export const rootLifecycleServiceFactory = createServiceFactory({ service: coreServices.rootLifecycle, diff --git a/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.ts b/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.ts index dc9b3a9864..b4370761d9 100644 --- a/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.ts @@ -20,7 +20,10 @@ import { } from '@backstage/backend-plugin-api'; import { TaskScheduler } from '@backstage/backend-tasks'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/scheduler` instead. + */ export const schedulerServiceFactory = createServiceFactory({ service: coreServices.scheduler, deps: { diff --git a/packages/backend-app-api/src/services/implementations/tokenManager/tokenManagerServiceFactory.ts b/packages/backend-app-api/src/services/implementations/tokenManager/tokenManagerServiceFactory.ts index cad1b06035..e7c4ce7af0 100644 --- a/packages/backend-app-api/src/services/implementations/tokenManager/tokenManagerServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/tokenManager/tokenManagerServiceFactory.ts @@ -20,7 +20,10 @@ import { } from '@backstage/backend-plugin-api'; import { ServerTokenManager } from '@backstage/backend-common'; -/** @public */ +/** + * @public + * @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead + */ export const tokenManagerServiceFactory = createServiceFactory({ service: coreServices.tokenManager, deps: { diff --git a/packages/backend-app-api/src/services/implementations/urlReader/urlReaderServiceFactory.ts b/packages/backend-app-api/src/services/implementations/urlReader/urlReaderServiceFactory.ts index 7f404a24b1..44caf25ad6 100644 --- a/packages/backend-app-api/src/services/implementations/urlReader/urlReaderServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/urlReader/urlReaderServiceFactory.ts @@ -20,7 +20,10 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; -/** @public */ +/** + * @public + * @deprecated Please import from `@backstage/backend-defaults/urlReader` instead. + */ export const urlReaderServiceFactory = createServiceFactory({ service: coreServices.urlReader, deps: { diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 1bf38a8c40..ddad94de14 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -20,9 +20,9 @@ import { coreServices, ServiceRef, ServiceFactory, + LifecycleService, + RootLifecycleService, } from '@backstage/backend-plugin-api'; -import { BackendLifecycleImpl } from '../services/implementations/rootLifecycle/rootLifecycleServiceFactory'; -import { BackendPluginLifecycleImpl } from '../services/implementations/lifecycle/lifecycleServiceFactory'; import { ServiceOrExtensionPoint } from './types'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-forbidden-package-imports @@ -345,27 +345,42 @@ export class BackendInitializer { } // Bit of a hacky way to grab the lifecycle services, potentially find a nicer way to do this - async #getRootLifecycleImpl(): Promise { + async #getRootLifecycleImpl(): Promise< + RootLifecycleService & { + startup(): Promise; + shutdown(): Promise; + } + > { const lifecycleService = await this.#serviceRegistry.get( coreServices.rootLifecycle, 'root', ); - if (lifecycleService instanceof BackendLifecycleImpl) { - return lifecycleService; + + const service = lifecycleService as any; + if ( + service && + typeof service.startup === 'function' && + typeof service.shutdown === 'function' + ) { + return service; } + throw new Error('Unexpected root lifecycle service implementation'); } async #getPluginLifecycleImpl( pluginId: string, - ): Promise { + ): Promise }> { const lifecycleService = await this.#serviceRegistry.get( coreServices.lifecycle, pluginId, ); - if (lifecycleService instanceof BackendPluginLifecycleImpl) { - return lifecycleService; + + const service = lifecycleService as any; + if (service && typeof service.startup === 'function') { + return service; } + throw new Error('Unexpected plugin lifecycle service implementation'); } } diff --git a/packages/backend-common/api-report-alpha.md b/packages/backend-common/api-report-alpha.md index 9d60b68b6b..046e8264fb 100644 --- a/packages/backend-common/api-report-alpha.md +++ b/packages/backend-common/api-report-alpha.md @@ -5,14 +5,14 @@ ```ts import { Duration } from 'luxon'; -// @alpha +// @alpha @deprecated export interface Context { readonly abortSignal: AbortSignal; readonly deadline: Date | undefined; value(key: string): T | undefined; } -// @alpha +// @alpha @deprecated export class Contexts { static root(): Context; static withAbort( diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 650f50a159..4e92420667 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -35,6 +35,7 @@ import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api'; import { HttpAuthService } from '@backstage/backend-plugin-api'; import { IdentityService } from '@backstage/backend-plugin-api'; import { isChildPath as isChildPath_2 } from '@backstage/backend-plugin-api'; +import { isDatabaseConflictError as isDatabaseConflictError_2 } from '@backstage/backend-plugin-api'; import { KubeConfig } from '@kubernetes/client-node'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoadConfigOptionsRemote } from '@backstage/config-loader'; @@ -265,7 +266,7 @@ export function createRootLogger( env?: NodeJS.ProcessEnv, ): winston.Logger; -// @public +// @public @deprecated export function createServiceBuilder(_module: NodeModule): ServiceBuilder; // @public @@ -309,7 +310,7 @@ export function dropDatabase( ...databaseNames: string[] ): Promise; -// @public +// @public @deprecated export function errorHandler( options?: ErrorHandlerOptions, ): ErrorRequestHandler; @@ -532,14 +533,14 @@ export class HarnessUrlReader implements UrlReader { toString(): string; } -// @public +// @public @deprecated export const HostDiscovery: typeof HostDiscovery_2; // @public @deprecated (undocumented) export const isChildPath: typeof isChildPath_2; -// @public -export function isDatabaseConflictError(e: unknown): boolean; +// @public @deprecated (undocumented) +export const isDatabaseConflictError: typeof isDatabaseConflictError_2; // @public export class KubernetesContainerRunner implements ContainerRunner { @@ -599,7 +600,7 @@ export type LegacyRootDatabaseService = { forPlugin(pluginId: string): PluginDatabaseManager; }; -// @public +// @public @deprecated export function loadBackendConfig(options: { logger: LoggerService; remote?: LoadConfigOptionsRemote; @@ -733,7 +734,7 @@ export function redactWinstonLogLine( info: winston.Logform.TransformableInfo, ): winston.Logform.TransformableInfo; -// @public +// @public @deprecated export function requestLoggingHandler(logger?: LoggerService): RequestHandler; // @public diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index c6263cde97..ed253dd54d 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -191,6 +191,11 @@ export interface Config { connection: string; /** An optional default TTL (in milliseconds). */ defaultTtl?: number; + /** + * Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache. + * Defaults to true if unspecified. + */ + useRedisSets?: boolean; } | { store: 'memcache'; diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index b452d8db70..1dfa9643e2 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -29,6 +29,7 @@ import { setRootLoggerRedactionList } from './logging/createRootLogger'; * This function should only be called once, during the initialization of the backend. * * @public + * @deprecated Use {@link @backstage/backend-app-api#loadBackendConfig} instead. */ export async function loadBackendConfig(options: { logger: LoggerService; diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts index 4eeb56b029..026e3ce4e0 100644 --- a/packages/backend-common/src/context/Contexts.ts +++ b/packages/backend-common/src/context/Contexts.ts @@ -24,6 +24,7 @@ import { ValueContext } from './ValueContext'; * Common context decorators. * * @alpha + * @deprecated This class is not used in the new Backend system, so it is going to be removed in a near future. */ export class Contexts { /** diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index 3b53225163..d980f54e43 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -19,6 +19,7 @@ * to pass along scoped information and abort signals. * * @alpha + * @deprecated This type is not used in the new Backend system, so it is going to be removed in a near future. */ export interface Context { /** diff --git a/packages/backend-common/src/database/connectors/mysql.ts b/packages/backend-common/src/database/connectors/mysql.ts index 1ad8b0b3be..2fb3545047 100644 --- a/packages/backend-common/src/database/connectors/mysql.ts +++ b/packages/backend-common/src/database/connectors/mysql.ts @@ -304,7 +304,7 @@ export class MysqlConnector implements Connector { const pluginDivisionMode = this.getPluginDivisionModeConfig(); if (pluginDivisionMode !== 'database') { throw new Error( - `The MySQL driver does not suppoert plugin division mode '${pluginDivisionMode}'`, + `The MySQL driver does not support plugin division mode '${pluginDivisionMode}'`, ); } diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index a0a61ccdd0..77d75653b8 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -21,4 +21,3 @@ export type { } from './DatabaseManager'; export type { PluginDatabaseManager } from './types'; -export { isDatabaseConflictError } from './util'; diff --git a/packages/backend-common/src/deprecated/index.ts b/packages/backend-common/src/deprecated/index.ts index 708e358bfc..d9ca795c3a 100644 --- a/packages/backend-common/src/deprecated/index.ts +++ b/packages/backend-common/src/deprecated/index.ts @@ -17,6 +17,7 @@ export * from './scm'; import { + isDatabaseConflictError as _isDatabaseConflictError, resolvePackagePath as _resolvePackagePath, resolveSafeChildPath as _resolveSafeChildPath, isChildPath as _isChildPath, @@ -24,21 +25,28 @@ import { /** * @public - * @deprecated This type is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493. + * @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493. + * Please use the `isDatabaseConflictError` function from the `@backstage/backend-plugin-api` package instead. + */ +export const isDatabaseConflictError = _isDatabaseConflictError; + +/** + * @public + * @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493. * Please use the `resolvePackagePath` function from the `@backstage/backend-plugin-api` package instead. */ export const resolvePackagePath = _resolvePackagePath; /** * @public - * @deprecated This type is deprecated and will be removed in a future release, see + * @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493. * Please use the `resolveSafeChildPath` function from the `@backstage/backend-plugin-api` package instead. */ export const resolveSafeChildPath = _resolveSafeChildPath; /** * @public - * @deprecated This type is deprecated and will be removed in a future release, see + * @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493. * Please use the `isChildPath` function from the `@backstage/cli-common` package instead. */ export const isChildPath = _isChildPath; diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index 38ac975747..cf0ddff611 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -27,6 +27,7 @@ export type { DiscoveryService as PluginEndpointDiscovery } from '@backstage/bac * resolved to the same host, so there won't be any balancing of internal traffic. * * @public + * @deprecated Please import from `@backstage/backend-defaults/discovery` instead. */ export const HostDiscovery = _HostDiscovery; diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 99ede75ab0..110734b0a2 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -61,6 +61,7 @@ export type ErrorHandlerOptions = { * * @public * @returns An Express error request handler + * @deprecated Use {@link @backstage/backend-app-api#MiddlewareFactory.create.error} instead */ export function errorHandler( options: ErrorHandlerOptions = {}, diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.ts b/packages/backend-common/src/middleware/requestLoggingHandler.ts index a03e3c31f6..81dfa1d345 100644 --- a/packages/backend-common/src/middleware/requestLoggingHandler.ts +++ b/packages/backend-common/src/middleware/requestLoggingHandler.ts @@ -26,6 +26,7 @@ import { ConfigReader } from '@backstage/config'; * @public * @param logger - An optional logger to use. If not specified, the root logger will be used. * @returns An Express request handler + * @deprecated @deprecated Use {@link @backstage/backend-app-api#MiddlewareFactory.create.logging} instead */ export function requestLoggingHandler(logger?: LoggerService): RequestHandler { return MiddlewareFactory.create({ diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index ce66e4e215..38936806c6 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -79,7 +79,7 @@ describe('FetchUrlReader', () => { worker.use( rest.get('https://backstage.io/error', (_req, res, ctx) => { - return res(ctx.status(500), ctx.body('An internal error occured')); + return res(ctx.status(500), ctx.body('An internal error occurred')); }), ); }); diff --git a/packages/backend-common/src/service/createServiceBuilder.ts b/packages/backend-common/src/service/createServiceBuilder.ts index a6ca25415e..e731056f72 100644 --- a/packages/backend-common/src/service/createServiceBuilder.ts +++ b/packages/backend-common/src/service/createServiceBuilder.ts @@ -19,8 +19,8 @@ import { ServiceBuilder } from './types'; /** * Creates a new service builder. - * * @public + * @deprecated We are going to deprecated this old way of creating services in a near future, if you are using this service helper, please checkout the {@link https://backstage.io/docs/backend-system/building-backends/migrating | backend} and {@link https://backstage.io/docs/backend-system/building-plugins-and-modules/migrating | plugin} migration guides. */ export function createServiceBuilder(_module: NodeModule): ServiceBuilder { return new ServiceBuilderImpl(_module); diff --git a/packages/backend-defaults/api-report-cache.md b/packages/backend-defaults/api-report-cache.md new file mode 100644 index 0000000000..150ed391f0 --- /dev/null +++ b/packages/backend-defaults/api-report-cache.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CacheClient } from '@backstage/backend-common'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const cacheServiceFactory: () => ServiceFactory; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-database.md b/packages/backend-defaults/api-report-database.md new file mode 100644 index 0000000000..512e1febc9 --- /dev/null +++ b/packages/backend-defaults/api-report-database.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const databaseServiceFactory: () => ServiceFactory< + PluginDatabaseManager, + 'plugin' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-discovery.md b/packages/backend-defaults/api-report-discovery.md new file mode 100644 index 0000000000..0ff734b8a7 --- /dev/null +++ b/packages/backend-defaults/api-report-discovery.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const discoveryServiceFactory: () => ServiceFactory< + DiscoveryService, + 'plugin' +>; + +// @public +export class HostDiscovery implements DiscoveryService { + static fromConfig( + config: Config, + options?: { + basePath?: string; + }, + ): HostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-lifecycle.md b/packages/backend-defaults/api-report-lifecycle.md new file mode 100644 index 0000000000..4233584664 --- /dev/null +++ b/packages/backend-defaults/api-report-lifecycle.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { LifecycleService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public +export const lifecycleServiceFactory: () => ServiceFactory< + LifecycleService, + 'plugin' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-permissions.md b/packages/backend-defaults/api-report-permissions.md new file mode 100644 index 0000000000..9006a62018 --- /dev/null +++ b/packages/backend-defaults/api-report-permissions.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { PermissionsService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const permissionsServiceFactory: () => ServiceFactory< + PermissionsService, + 'plugin' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-rootConfig.md b/packages/backend-defaults/api-report-rootConfig.md new file mode 100644 index 0000000000..60c8fbcb57 --- /dev/null +++ b/packages/backend-defaults/api-report-rootConfig.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { RemoteConfigSourceOptions } from '@backstage/config-loader'; +import { RootConfigService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export interface RootConfigFactoryOptions { + argv?: string[]; + remote?: Pick; + // (undocumented) + watch?: boolean; +} + +// @public (undocumented) +export const rootConfigServiceFactory: ( + options?: RootConfigFactoryOptions | undefined, +) => ServiceFactory; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-rootLifecycle.md b/packages/backend-defaults/api-report-rootLifecycle.md new file mode 100644 index 0000000000..00eb9c1ebb --- /dev/null +++ b/packages/backend-defaults/api-report-rootLifecycle.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { RootLifecycleService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public +export const rootLifecycleServiceFactory: () => ServiceFactory< + RootLifecycleService, + 'root' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-scheduler.md b/packages/backend-defaults/api-report-scheduler.md new file mode 100644 index 0000000000..a2dad43d60 --- /dev/null +++ b/packages/backend-defaults/api-report-scheduler.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { SchedulerService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public +export const schedulerServiceFactory: () => ServiceFactory< + SchedulerService, + 'plugin' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/api-report-urlReader.md b/packages/backend-defaults/api-report-urlReader.md new file mode 100644 index 0000000000..8c7147bd7c --- /dev/null +++ b/packages/backend-defaults/api-report-urlReader.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ServiceFactory } from '@backstage/backend-plugin-api'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export const urlReaderServiceFactory: () => ServiceFactory; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts new file mode 100644 index 0000000000..569ab436db --- /dev/null +++ b/packages/backend-defaults/config.d.ts @@ -0,0 +1,39 @@ +/* + * 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 interface Config { + /** + * Options used by the default discovery service. + */ + discovery?: { + /** + * A list of target baseUrls and the associated plugins. + */ + endpoints: Array<{ + /** + * The target base URL to use for the plugin. + * + * Can be either a string or an object with internal and external keys. + * Targets with `{{pluginId}}` or `{{ pluginId }} in the URL will be replaced with the plugin ID. + */ + target: string | { internal: string; external: string }; + /** + * Array of plugins which use the target base URL. + */ + plugins: string[]; + }>; + }; +} diff --git a/packages/backend-defaults/migrations/scheduler/20210928160613_init.js b/packages/backend-defaults/migrations/scheduler/20210928160613_init.js new file mode 100644 index 0000000000..f9900cab11 --- /dev/null +++ b/packages/backend-defaults/migrations/scheduler/20210928160613_init.js @@ -0,0 +1,64 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // + // tasks + // + await knex.schema.createTable('backstage_backend_tasks__tasks', table => { + table.comment('Tasks used for scheduling work on multiple workers'); + table + .string('id') + .primary() + .notNullable() + .comment('The unique ID of this particular task'); + table + .text('settings_json') + .notNullable() + .comment('JSON serialized object with properties for this task'); + table + .dateTime('next_run_start_at') + .notNullable() + .comment('The next time that the task should be started'); + table + .text('current_run_ticket') + .nullable() + .comment('A unique ticket for the current task run'); + table + .dateTime('current_run_started_at') + .nullable() + .comment('The time that the current task run started'); + table + .dateTime('current_run_expires_at') + .nullable() + .comment('The time that the current task run will time out'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + // + // tasks + // + await knex.schema.dropTable('backstage_backend_tasks__tasks'); +}; diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index b5da7abbcf..4f502b1598 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -2,46 +2,108 @@ "name": "@backstage/backend-defaults", "description": "Backend defaults used by Backstage backend apps", "version": "0.2.18", - "main": "src/index.ts", - "types": "src/index.ts", - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, "backstage": { "role": "node-library" }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "backstage" + ], "homepage": "https://backstage.io", "repository": { "type": "git", "url": "https://github.com/backstage/backstage", "directory": "packages/backend-defaults" }, - "keywords": [ - "backstage" - ], "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./cache": "./src/entrypoints/cache/index.ts", + "./database": "./src/entrypoints/database/index.ts", + "./discovery": "./src/entrypoints/discovery/index.ts", + "./lifecycle": "./src/entrypoints/lifecycle/index.ts", + "./permissions": "./src/entrypoints/permissions/index.ts", + "./rootConfig": "./src/entrypoints/rootConfig/index.ts", + "./rootLifecycle": "./src/entrypoints/rootLifecycle/index.ts", + "./scheduler": "./src/entrypoints/scheduler/index.ts", + "./urlReader": "./src/entrypoints/urlReader/index.ts", + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "cache": [ + "src/entrypoints/cache/index.ts" + ], + "database": [ + "src/entrypoints/database/index.ts" + ], + "discovery": [ + "src/entrypoints/discovery/index.ts" + ], + "lifecycle": [ + "src/entrypoints/lifecycle/index.ts" + ], + "permissions": [ + "src/entrypoints/permissions/index.ts" + ], + "rootConfig": [ + "src/entrypoints/rootConfig/index.ts" + ], + "rootLifecycle": [ + "src/entrypoints/rootLifecycle/index.ts" + ], + "scheduler": [ + "src/entrypoints/scheduler/index.ts" + ], + "urlReader": [ + "src/entrypoints/urlReader/index.ts" + ], + "package.json": [ + "package.json" + ] + } + }, + "files": [ + "config.d.ts", + "dist", + "migrations" + ], "scripts": { "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "clean": "backstage-cli package clean", - "start": "backstage-cli package start" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", - "@backstage/plugin-events-node": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", + "@backstage/types": "workspace:^", + "@opentelemetry/api": "^1.3.0", + "cron": "^3.0.0", + "knex": "^3.0.0", + "lodash": "^4.17.21", + "luxon": "^3.0.0", + "uuid": "^9.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "wait-for-expect": "^3.0.2" }, - "files": [ - "dist" - ] + "configSchema": "config.d.ts" } diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index e9bdc03262..823b3c2833 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -16,26 +16,26 @@ import { Backend, - cacheServiceFactory, - rootConfigServiceFactory, - createSpecializedBackend, - databaseServiceFactory, - discoveryServiceFactory, - httpRouterServiceFactory, - rootHttpRouterServiceFactory, - lifecycleServiceFactory, - rootLifecycleServiceFactory, - loggerServiceFactory, - permissionsServiceFactory, - rootLoggerServiceFactory, - schedulerServiceFactory, - tokenManagerServiceFactory, - urlReaderServiceFactory, - identityServiceFactory, authServiceFactory, + createSpecializedBackend, httpAuthServiceFactory, + httpRouterServiceFactory, + identityServiceFactory, + loggerServiceFactory, + rootHttpRouterServiceFactory, + rootLoggerServiceFactory, + tokenManagerServiceFactory, userInfoServiceFactory, } from '@backstage/backend-app-api'; +import { cacheServiceFactory } from '@backstage/backend-defaults/cache'; +import { databaseServiceFactory } from '@backstage/backend-defaults/database'; +import { discoveryServiceFactory } from '@backstage/backend-defaults/discovery'; +import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle'; +import { permissionsServiceFactory } from '@backstage/backend-defaults/permissions'; +import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig'; +import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle'; +import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler'; +import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { eventsServiceFactory } from '@backstage/plugin-events-node'; export const defaultServiceFactories = [ diff --git a/plugins/user-settings-backend/src/run.ts b/packages/backend-defaults/src/entrypoints/cache/cacheServiceFactory.ts similarity index 53% rename from plugins/user-settings-backend/src/run.ts rename to packages/backend-defaults/src/entrypoints/cache/cacheServiceFactory.ts index 178de0f786..d348c455d2 100644 --- a/plugins/user-settings-backend/src/run.ts +++ b/packages/backend-defaults/src/entrypoints/cache/cacheServiceFactory.ts @@ -14,21 +14,25 @@ * limitations under the License. */ -import { getRootLogger } from '@backstage/backend-common'; -import yn from 'yn'; +import { CacheManager } from '@backstage/backend-common'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; -import { startStandaloneServer } from './service/standaloneServer'; - -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; -const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); -const logger = getRootLogger(); - -startStandaloneServer({ port, enableCors, logger }).catch(err => { - logger.error(err); - process.exit(1); -}); - -process.on('SIGINT', () => { - logger.info('CTRL+C pressed; exiting.'); - process.exit(0); +/** + * @public + */ +export const cacheServiceFactory = createServiceFactory({ + service: coreServices.cache, + deps: { + config: coreServices.rootConfig, + plugin: coreServices.pluginMetadata, + }, + async createRootContext({ config }) { + return CacheManager.fromConfig(config); + }, + async factory({ plugin }, manager) { + return manager.forPlugin(plugin.getId()).getClient(); + }, }); diff --git a/packages/backend-defaults/src/entrypoints/cache/index.ts b/packages/backend-defaults/src/entrypoints/cache/index.ts new file mode 100644 index 0000000000..f96ee77182 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/cache/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 { cacheServiceFactory } from './cacheServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts new file mode 100644 index 0000000000..12e4e569bd --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts @@ -0,0 +1,51 @@ +/* + * 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 { DatabaseManager } from '@backstage/backend-common'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { ConfigReader } from '@backstage/config'; + +/** + * @public + */ +export const databaseServiceFactory = createServiceFactory({ + service: coreServices.database, + deps: { + config: coreServices.rootConfig, + lifecycle: coreServices.lifecycle, + pluginMetadata: coreServices.pluginMetadata, + }, + async createRootContext({ config }) { + return config.getOptional('backend.database') + ? DatabaseManager.fromConfig(config) + : DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { client: 'better-sqlite3', connection: ':memory:' }, + }, + }), + ); + }, + async factory({ pluginMetadata, lifecycle }, databaseManager) { + return databaseManager.forPlugin(pluginMetadata.getId(), { + pluginMetadata, + lifecycle, + }); + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/database/index.ts b/packages/backend-defaults/src/entrypoints/database/index.ts new file mode 100644 index 0000000000..d676c8013e --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/database/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 { databaseServiceFactory } from './databaseServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts new file mode 100644 index 0000000000..4e6aff5853 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts @@ -0,0 +1,257 @@ +/* + * 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. + */ + +import { ConfigReader } from '@backstage/config'; +import { HostDiscovery } from './HostDiscovery'; + +describe('HostDiscovery', () => { + it('is created from config', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/api/catalog', + ); + }); + + it('strips trailing slashes in config', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40//', + listen: { port: 80, host: 'localhost' }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/api/catalog', + ); + }); + + it('can configure the base path', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + { basePath: '/service' }, + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/service/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/service/catalog', + ); + }); + + it.each([ + [{ listen: ':80' }, 'http://localhost:80'], + [{ listen: ':40', https: true }, 'https://localhost:40'], + [{ listen: '127.0.0.1:80' }, 'http://127.0.0.1:80'], + [{ listen: '127.0.0.1:80', https: true }, 'https://127.0.0.1:80'], + [{ listen: '0.0.0.0:40' }, 'http://127.0.0.1:40'], + [{ listen: { port: 80 } }, 'http://localhost:80'], + [{ listen: { port: 8000 } }, 'http://localhost:8000'], + [{ listen: { port: 80, host: '0.0.0.0' } }, 'http://127.0.0.1:80'], + [{ listen: { port: 80, host: '::' } }, 'http://localhost:80'], + [{ listen: { port: 80, host: '::1' } }, 'http://[::1]:80'], + [{ listen: { port: 90, host: '::2' }, https: true }, 'https://[::2]:90'], + ])('resolves internal baseUrl for %j as %s', async (config, expected) => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + ...config, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + `${expected}/api/catalog`, + ); + }); + + it('uses plugin specific targets from config if provided', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: { + internal: 'http://catalog-backend-internal:8080/api/catalog', + external: 'http://catalog-backend-external:8080/api/catalog', + }, + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend-internal:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend-external:8080/api/catalog', + ); + }); + + it('uses a single target for internal and external for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + }); + + it('defaults to the backend baseUrl when there is not an endpoint for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://localhost:80/api/scaffolder', + ); + await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe( + 'http://localhost:40/api/scaffolder', + ); + }); + + it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{{pluginId}}', + plugins: ['catalog', 'docs'], + }, + { + target: { + internal: 'http://scaffolder-internal:8080/api/{{ pluginId }}', + external: 'http://scaffolder-external:8080/api/{{ pluginId }}', + }, + plugins: ['scaffolder'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://common-backend:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://common-backend:8080/api/catalog', + ); + await expect(discovery.getBaseUrl('docs')).resolves.toBe( + 'http://common-backend:8080/api/docs', + ); + await expect(discovery.getExternalBaseUrl('docs')).resolves.toBe( + 'http://common-backend:8080/api/docs', + ); + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://scaffolder-internal:8080/api/scaffolder', + ); + await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe( + 'http://scaffolder-external:8080/api/scaffolder', + ); + }); + + it('encodes the pluginId', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{{pluginId}}', + plugins: ['plugin/beta'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('plugin/beta')).resolves.toBe( + 'http://common-backend:8080/api/plugin%2Fbeta', + ); + await expect(discovery.getBaseUrl('plugin/alpha')).resolves.toBe( + 'http://localhost:80/api/plugin%2Falpha', + ); + await expect(discovery.getExternalBaseUrl('plugin/alpha')).resolves.toBe( + 'http://localhost:40/api/plugin%2Falpha', + ); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts new file mode 100644 index 0000000000..180c8706d5 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts @@ -0,0 +1,132 @@ +/* + * 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. + */ + +import { Config } from '@backstage/config'; +import { readHttpServerOptions } from '@backstage/backend-app-api'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; + +type Target = string | { internal: string; external: string }; + +/** + * HostDiscovery is a basic PluginEndpointDiscovery implementation + * that can handle plugins that are hosted in a single or multiple deployments. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + * + * @public + */ +export class HostDiscovery implements DiscoveryService { + /** + * Creates a new HostDiscovery discovery instance by reading + * from the `backend` config section, specifically the `.baseUrl` for + * discovering the external URL, and the `.listen` and `.https` config + * for the internal one. + * + * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`. + * eg. + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permission] + * - target: + * internal: https://internal.example.com/search + * external: https://example.com/search + * plugins: [search] + * ``` + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config + .getString('backend.baseUrl') + .replace(/\/+$/, ''); + + const { + listen: { host: listenHost = '::', port: listenPort }, + } = readHttpServerOptions(config.getConfig('backend')); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::' || host === '') { + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new HostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + config.getOptionalConfig('discovery'), + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + private readonly discoveryConfig: Config | undefined, + ) {} + + private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') { + const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); + + const target = endpoints + ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) + ?.get('target'); + + if (!target) { + const baseUrl = + type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; + + return `${baseUrl}/${encodeURIComponent(pluginId)}`; + } + + if (typeof target === 'string') { + return target.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + return target[type].replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + async getBaseUrl(pluginId: string): Promise { + return this.getTargetFromConfig(pluginId, 'internal'); + } + + async getExternalBaseUrl(pluginId: string): Promise { + return this.getTargetFromConfig(pluginId, 'external'); + } +} diff --git a/packages/theme/src/unified/MuiClassNameSetup.ts b/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts similarity index 61% rename from packages/theme/src/unified/MuiClassNameSetup.ts rename to packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts index 5d7e0b26fe..bfc5a6a489 100644 --- a/packages/theme/src/unified/MuiClassNameSetup.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts @@ -14,11 +14,19 @@ * limitations under the License. */ -import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { HostDiscovery } from './HostDiscovery'; -/** - * This API is introduced in @mui/material (v5.0.5) as a replacement of deprecated createGenerateClassName & only affects v5 Material UI components from `@mui/*` - */ -ClassNameGenerator.configure(componentName => { - return `v5-${componentName}`; +/** @public */ +export const discoveryServiceFactory = createServiceFactory({ + service: coreServices.discovery, + deps: { + config: coreServices.rootConfig, + }, + async factory({ config }) { + return HostDiscovery.fromConfig(config); + }, }); diff --git a/packages/backend-defaults/src/entrypoints/discovery/index.ts b/packages/backend-defaults/src/entrypoints/discovery/index.ts new file mode 100644 index 0000000000..ee4851271a --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/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 { discoveryServiceFactory } from './discoveryServiceFactory'; +export { HostDiscovery } from './HostDiscovery'; diff --git a/packages/backend-defaults/src/entrypoints/lifecycle/index.ts b/packages/backend-defaults/src/entrypoints/lifecycle/index.ts new file mode 100644 index 0000000000..8dac4c26b4 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/lifecycle/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 { lifecycleServiceFactory } from './lifecycleServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts new file mode 100644 index 0000000000..3eb43d6c6e --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts @@ -0,0 +1,106 @@ +/* + * 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 { + LifecycleService, + LifecycleServiceShutdownHook, + LifecycleServiceShutdownOptions, + LifecycleServiceStartupHook, + LifecycleServiceStartupOptions, + LoggerService, + PluginMetadataService, + RootLifecycleService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; + +/** @internal */ +export class BackendPluginLifecycleImpl implements LifecycleService { + constructor( + private readonly logger: LoggerService, + private readonly rootLifecycle: RootLifecycleService, + private readonly pluginMetadata: PluginMetadataService, + ) {} + + #hasStarted = false; + #startupTasks: Array<{ + hook: LifecycleServiceStartupHook; + options?: LifecycleServiceStartupOptions; + }> = []; + + addStartupHook( + hook: LifecycleServiceStartupHook, + options?: LifecycleServiceStartupOptions, + ): void { + if (this.#hasStarted) { + throw new Error('Attempted to add startup hook after startup'); + } + this.#startupTasks.push({ hook, options }); + } + + async startup(): Promise { + if (this.#hasStarted) { + return; + } + this.#hasStarted = true; + + this.logger.debug( + `Running ${this.#startupTasks.length} plugin startup tasks...`, + ); + await Promise.all( + this.#startupTasks.map(async ({ hook, options }) => { + const logger = options?.logger ?? this.logger; + try { + await hook(); + logger.debug(`Plugin startup hook succeeded`); + } catch (error) { + logger.error(`Plugin startup hook failed, ${error}`); + } + }), + ); + } + + addShutdownHook( + hook: LifecycleServiceShutdownHook, + options?: LifecycleServiceShutdownOptions, + ): void { + const plugin = this.pluginMetadata.getId(); + this.rootLifecycle.addShutdownHook(hook, { + logger: options?.logger?.child({ plugin }) ?? this.logger, + }); + } +} + +/** + * Allows plugins to register shutdown hooks that are run when the process is about to exit. + * + * @public + */ +export const lifecycleServiceFactory = createServiceFactory({ + service: coreServices.lifecycle, + deps: { + logger: coreServices.logger, + rootLifecycle: coreServices.rootLifecycle, + pluginMetadata: coreServices.pluginMetadata, + }, + async factory({ rootLifecycle, logger, pluginMetadata }) { + return new BackendPluginLifecycleImpl( + logger, + rootLifecycle, + pluginMetadata, + ); + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/permissions/index.ts b/packages/backend-defaults/src/entrypoints/permissions/index.ts new file mode 100644 index 0000000000..781dda31a0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/permissions/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 { permissionsServiceFactory } from './permissionsServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/permissions/permissionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/permissions/permissionsServiceFactory.ts new file mode 100644 index 0000000000..f675dd6719 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/permissions/permissionsServiceFactory.ts @@ -0,0 +1,41 @@ +/* + * 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 { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; + +/** + * @public + */ +export const permissionsServiceFactory = createServiceFactory({ + service: coreServices.permissions, + deps: { + auth: coreServices.auth, + config: coreServices.rootConfig, + discovery: coreServices.discovery, + tokenManager: coreServices.tokenManager, + }, + async factory({ auth, config, discovery, tokenManager }) { + return ServerPermissionClient.fromConfig(config, { + auth, + discovery, + tokenManager, + }); + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/rootConfig/index.ts b/packages/backend-defaults/src/entrypoints/rootConfig/index.ts new file mode 100644 index 0000000000..1775ef2efc --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootConfig/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 { rootConfigServiceFactory } from './rootConfigServiceFactory'; +export type { RootConfigFactoryOptions } from './rootConfigServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/rootConfig/rootConfigServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootConfig/rootConfigServiceFactory.ts new file mode 100644 index 0000000000..92d1a89c0f --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootConfig/rootConfigServiceFactory.ts @@ -0,0 +1,59 @@ +/* + * 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 { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { + ConfigSources, + RemoteConfigSourceOptions, +} from '@backstage/config-loader'; + +/** + * @public + */ +export interface RootConfigFactoryOptions { + /** + * Process arguments to use instead of the default `process.argv()`. + */ + argv?: string[]; + + /** + * Enables and sets options for remote configuration loading. + */ + remote?: Pick; + watch?: boolean; +} + +/** + * @public + */ +export const rootConfigServiceFactory = createServiceFactory( + (options?: RootConfigFactoryOptions) => ({ + service: coreServices.rootConfig, + deps: {}, + async factory() { + const source = ConfigSources.default({ + argv: options?.argv, + remote: options?.remote, + watch: options?.watch, + }); + console.log(`Loading config from ${source}`); + return await ConfigSources.toConfig(source); + }, + }), +); diff --git a/packages/backend-defaults/src/entrypoints/rootLifecycle/index.ts b/packages/backend-defaults/src/entrypoints/rootLifecycle/index.ts new file mode 100644 index 0000000000..86589cd23e --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLifecycle/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 { rootLifecycleServiceFactory } from './rootLifecycleServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.test.ts new file mode 100644 index 0000000000..91cb9031ae --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { BackendLifecycleImpl } from './rootLifecycleServiceFactory'; + +describe('lifecycleService', () => { + it('should execute registered shutdown hook', async () => { + const service = new BackendLifecycleImpl(getVoidLogger()); + const hook = jest.fn(); + service.addShutdownHook(() => hook()); + // should not execute the hook more than once. + await service.shutdown(); + await service.shutdown(); + await service.shutdown(); + expect(hook).toHaveBeenCalledTimes(1); + }); + + it('should not throw errors', async () => { + const service = new BackendLifecycleImpl(getVoidLogger()); + service.addShutdownHook(() => { + throw new Error('oh no'); + }); + await expect(service.shutdown()).resolves.toBeUndefined(); + }); + + it('should not throw async errors', async () => { + const service = new BackendLifecycleImpl(getVoidLogger()); + service.addShutdownHook(async () => { + throw new Error('oh no'); + }); + await expect(service.shutdown()).resolves.toBeUndefined(); + }); + + it('should reject hooks after trigger', async () => { + const service = new BackendLifecycleImpl(getVoidLogger()); + await service.startup(); + expect(() => { + service.addStartupHook(() => {}); + }).toThrow('Attempted to add startup hook after startup'); + + await service.shutdown(); + expect(() => { + service.addShutdownHook(() => {}); + }).toThrow('Attempted to add shutdown hook after shutdown'); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.ts new file mode 100644 index 0000000000..197f99b97a --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLifecycle/rootLifecycleServiceFactory.ts @@ -0,0 +1,120 @@ +/* + * 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 { + createServiceFactory, + coreServices, + LifecycleServiceStartupHook, + LifecycleServiceStartupOptions, + LifecycleServiceShutdownHook, + LifecycleServiceShutdownOptions, + RootLifecycleService, + LoggerService, +} from '@backstage/backend-plugin-api'; + +/** @internal */ +export class BackendLifecycleImpl implements RootLifecycleService { + constructor(private readonly logger: LoggerService) {} + + #hasStarted = false; + #startupTasks: Array<{ + hook: LifecycleServiceStartupHook; + options?: LifecycleServiceStartupOptions; + }> = []; + + addStartupHook( + hook: LifecycleServiceStartupHook, + options?: LifecycleServiceStartupOptions, + ): void { + if (this.#hasStarted) { + throw new Error('Attempted to add startup hook after startup'); + } + this.#startupTasks.push({ hook, options }); + } + + async startup(): Promise { + if (this.#hasStarted) { + return; + } + this.#hasStarted = true; + + this.logger.debug(`Running ${this.#startupTasks.length} startup tasks...`); + await Promise.all( + this.#startupTasks.map(async ({ hook, options }) => { + const logger = options?.logger ?? this.logger; + try { + await hook(); + logger.debug(`Startup hook succeeded`); + } catch (error) { + logger.error(`Startup hook failed, ${error}`); + } + }), + ); + } + + #hasShutdown = false; + #shutdownTasks: Array<{ + hook: LifecycleServiceShutdownHook; + options?: LifecycleServiceShutdownOptions; + }> = []; + + addShutdownHook( + hook: LifecycleServiceShutdownHook, + options?: LifecycleServiceShutdownOptions, + ): void { + if (this.#hasShutdown) { + throw new Error('Attempted to add shutdown hook after shutdown'); + } + this.#shutdownTasks.push({ hook, options }); + } + + async shutdown(): Promise { + if (this.#hasShutdown) { + return; + } + this.#hasShutdown = true; + + this.logger.debug( + `Running ${this.#shutdownTasks.length} shutdown tasks...`, + ); + await Promise.all( + this.#shutdownTasks.map(async ({ hook, options }) => { + const logger = options?.logger ?? this.logger; + try { + await hook(); + logger.debug(`Shutdown hook succeeded`); + } catch (error) { + logger.error(`Shutdown hook failed, ${error}`); + } + }), + ); + } +} + +/** + * Allows plugins to register shutdown hooks that are run when the process is about to exit. + * + * @public + */ +export const rootLifecycleServiceFactory = createServiceFactory({ + service: coreServices.rootLifecycle, + deps: { + logger: coreServices.rootLogger, + }, + async factory({ logger }) { + return new BackendLifecycleImpl(logger); + }, +}); diff --git a/plugins/search-backend/src/run.ts b/packages/backend-defaults/src/entrypoints/scheduler/database/migrateBackendTasks.ts similarity index 53% rename from plugins/search-backend/src/run.ts rename to packages/backend-defaults/src/entrypoints/scheduler/database/migrateBackendTasks.ts index 53d4e4334a..959e6097f4 100644 --- a/plugins/search-backend/src/run.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/database/migrateBackendTasks.ts @@ -14,20 +14,18 @@ * limitations under the License. */ -import { getRootLogger } from '@backstage/backend-common'; -import yn from 'yn'; -import { startStandaloneServer } from './service/standaloneServer'; +import { resolvePackagePath } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { DB_MIGRATIONS_TABLE } from './tables'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; -const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); -const logger = getRootLogger(); +export async function migrateBackendTasks(knex: Knex): Promise { + const migrationsDir = resolvePackagePath( + '@backstage/backend-defaults', + 'migrations/scheduler', + ); -startStandaloneServer({ port, enableCors, logger }).catch(err => { - logger.error(err); - process.exit(1); -}); - -process.on('SIGINT', () => { - logger.info('CTRL+C pressed; exiting.'); - process.exit(0); -}); + await knex.migrate.latest({ + directory: migrationsDir, + tableName: DB_MIGRATIONS_TABLE, + }); +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/database/tables.ts b/packages/backend-defaults/src/entrypoints/scheduler/database/tables.ts new file mode 100644 index 0000000000..63aad6e42a --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/database/tables.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 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 DB_MIGRATIONS_TABLE = 'backstage_backend_tasks__knex_migrations'; +export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks'; + +export type DbTasksRow = { + id: string; + settings_json: string; + next_run_start_at: Date; + current_run_ticket?: string; + current_run_started_at?: Date | string; + current_run_expires_at?: Date | string; +}; diff --git a/packages/backend-defaults/src/entrypoints/scheduler/index.ts b/packages/backend-defaults/src/entrypoints/scheduler/index.ts new file mode 100644 index 0000000000..77d4ea561d --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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 { schedulerServiceFactory } from './schedulerServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.test.ts new file mode 100644 index 0000000000..4fd4949478 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { LocalTaskWorker } from './LocalTaskWorker'; + +describe('LocalTaskWorker', () => { + const logger = getVoidLogger(); + + it('runs the happy path (with iso duration) and handles cancellation', async () => { + const fn = jest.fn(); + const controller = new AbortController(); + + const worker = new LocalTaskWorker('a', fn, logger); + worker.start( + { + version: 2, + initialDelayDuration: 'PT0.2S', + cadence: 'PT0.2S', + timeoutAfterDuration: 'PT1S', + }, + { signal: controller.signal }, + ); + + // TODO(freben): Rewrite to fake timers - tried, but it wouldn't work + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 100)); + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 200)); + expect(fn).toHaveBeenCalledTimes(1); + await new Promise(r => setTimeout(r, 200)); + expect(fn).toHaveBeenCalledTimes(2); + controller.abort(); + await new Promise(r => setTimeout(r, 200)); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('runs the happy path (with a cron expression) and handles cancellation', async () => { + const fn = jest.fn(); + const controller = new AbortController(); + + // Await until system time is just past a second boundary (since cron is + // wall clock based) + await new Promise(r => setTimeout(r, 1000 - (Date.now() % 1000) + 10)); + + const worker = new LocalTaskWorker('a', fn, logger); + worker.start( + { + version: 2, + initialDelayDuration: 'PT0.2S', + cadence: '* * * * * *', + timeoutAfterDuration: 'PT1S', + }, + { signal: controller.signal }, + ); + + // TODO(freben): Rewrite to fake timers - tried, but it wouldn't work + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 100)); + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 200)); + expect(fn).toHaveBeenCalledTimes(1); + await new Promise(r => setTimeout(r, 1000)); + expect(fn).toHaveBeenCalledTimes(2); + controller.abort(); + await new Promise(r => setTimeout(r, 1000)); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('can trigger to abort wait', async () => { + const fn = jest.fn(); + const controller = new AbortController(); + + const worker = new LocalTaskWorker('a', fn, logger); + worker.start( + { + version: 2, + initialDelayDuration: 'PT0.2S', + cadence: 'PT0.2S', + timeoutAfterDuration: 'PT1S', + }, + { signal: controller.signal }, + ); + + // TODO(freben): Rewrite to fake timers - tried, but it wouldn't work + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 100)); + expect(fn).toHaveBeenCalledTimes(0); + await new Promise(r => setTimeout(r, 200)); + expect(fn).toHaveBeenCalledTimes(1); + worker.trigger(); + await new Promise(r => setTimeout(r, 10)); + expect(fn).toHaveBeenCalledTimes(2); + controller.abort(); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts new file mode 100644 index 0000000000..3510a855e9 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts @@ -0,0 +1,155 @@ +/* + * 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 { LoggerService } from '@backstage/backend-plugin-api'; +import { SchedulerServiceTaskFunction } from '@backstage/backend-plugin-api'; +import { ConflictError } from '@backstage/errors'; +import { CronTime } from 'cron'; +import { DateTime, Duration } from 'luxon'; +import { TaskSettingsV2 } from './types'; +import { delegateAbortController, sleep } from './util'; + +/** + * Implements tasks that run locally without cross-host collaboration. + * + * @private + */ +export class LocalTaskWorker { + private abortWait: AbortController | undefined; + + constructor( + private readonly taskId: string, + private readonly fn: SchedulerServiceTaskFunction, + private readonly logger: LoggerService, + ) {} + + start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) { + this.logger.info( + `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, + ); + + (async () => { + let attemptNum = 1; + for (;;) { + try { + if (settings.initialDelayDuration) { + await this.sleep( + Duration.fromISO(settings.initialDelayDuration), + options?.signal, + ); + } + + while (!options?.signal?.aborted) { + const startTime = process.hrtime(); + await this.runOnce(settings, options?.signal); + const timeTaken = process.hrtime(startTime); + await this.waitUntilNext( + settings, + (timeTaken[0] + timeTaken[1] / 1e9) * 1000, + options?.signal, + ); + } + + this.logger.info(`Task worker finished: ${this.taskId}`); + attemptNum = 0; + break; + } catch (e) { + attemptNum += 1; + this.logger.warn( + `Task worker failed unexpectedly, attempt number ${attemptNum}, ${e}`, + ); + await sleep(Duration.fromObject({ seconds: 1 })); + } + } + })(); + } + + trigger(): void { + if (!this.abortWait) { + throw new ConflictError(`Task ${this.taskId} is currently running`); + } + this.abortWait.abort(); + } + + /** + * Makes a single attempt at running the task to completion. + */ + private async runOnce( + settings: TaskSettingsV2, + signal?: AbortSignal, + ): Promise { + // Abort the task execution either if the worker is stopped, or if the + // task timeout is hit + const taskAbortController = delegateAbortController(signal); + const timeoutHandle = setTimeout(() => { + taskAbortController.abort(); + }, Duration.fromISO(settings.timeoutAfterDuration).as('milliseconds')); + + try { + await this.fn(taskAbortController.signal); + } catch (e) { + // ignore intentionally + } + + // release resources + clearTimeout(timeoutHandle); + taskAbortController.abort(); + } + + /** + * Sleeps until it's time to run the task again. + */ + private async waitUntilNext( + settings: TaskSettingsV2, + lastRunMillis: number, + signal?: AbortSignal, + ) { + if (signal?.aborted) { + return; + } + + const isCron = !settings.cadence.startsWith('P'); + let dt: number; + + if (isCron) { + const nextRun = +new CronTime(settings.cadence).sendAt().toJSDate(); + dt = nextRun - Date.now(); + } else { + dt = + Duration.fromISO(settings.cadence).as('milliseconds') - lastRunMillis; + } + + dt = Math.max(dt, 0); + + this.logger.debug( + `task: ${this.taskId} will next occur around ${DateTime.now().plus( + Duration.fromMillis(dt), + )}`, + ); + + await this.sleep(Duration.fromMillis(dt), signal); + } + + private async sleep( + duration: Duration, + abortSignal?: AbortSignal, + ): Promise { + this.abortWait = delegateAbortController(abortSignal); + await sleep(duration, this.abortWait.signal); + this.abortWait.abort(); // cleans up resources + this.abortWait = undefined; + } +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts new file mode 100644 index 0000000000..57faa6faf8 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts @@ -0,0 +1,349 @@ +/* + * Copyright 2021 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { ConflictError, NotFoundError } from '@backstage/errors'; +import { Duration } from 'luxon'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { + PluginTaskSchedulerImpl, + parseDuration, +} from './PluginTaskSchedulerImpl'; + +function defer() { + let resolve = () => {}; + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + return { promise, resolve }; +} + +jest.setTimeout(60_000); + +describe('PluginTaskManagerImpl', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'], + }); + + beforeAll(async () => { + // Make sure all databases are running before mocking timers, in case of testcontainers + await Promise.all( + databases.eachSupportedId().map(([id]) => databases.init(id)), + ); + + jest.useFakeTimers(); + }, 60_000); + + async function init(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + const manager = new PluginTaskSchedulerImpl( + async () => knex, + getVoidLogger(), + ); + return { knex, manager }; + } + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('scheduleTask with global scope', () => { + it.each(databases.eachSupportedId())( + 'can run the v1 happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + scope: 'global', + }); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, + ); + + it.each(databases.eachSupportedId())( + 'can run the v2 happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: { cron: '* * * * * *' }, + fn, + scope: 'global', + }); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, + ); + }); + + describe('triggerTask with global scope', () => { + it.each(databases.eachSupportedId())( + 'can manually trigger a task, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + initialDelay: Duration.fromObject({ years: 1 }), + fn, + scope: 'global', + }); + + await manager.triggerTask('task1'); + jest.advanceTimersByTime(5000); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, + ); + + it.each(databases.eachSupportedId())( + 'cant trigger a non-existent task, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + fn, + scope: 'global', + }); + + await expect(() => manager.triggerTask('task2')).rejects.toThrow( + NotFoundError, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'cant trigger a running task, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const { promise, resolve } = defer(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + fn: async () => { + resolve(); + await new Promise(r => setTimeout(r, 20000)); + }, + scope: 'global', + }); + + await promise; + await expect(() => manager.triggerTask('task1')).rejects.toThrow( + ConflictError, + ); + }, + ); + }); + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('scheduleTask with local scope', () => { + it('can run the v1 happy path', async () => { + const { manager } = await init('SQLITE_3'); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task1', + timeout: { milliseconds: 5000 }, + frequency: { milliseconds: 5000 }, + fn, + scope: 'local', + }); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, 60_000); + + it('can run the v2 happy path', async () => { + const { manager } = await init('SQLITE_3'); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: { cron: '* * * * * *' }, + fn, + scope: 'local', + }); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, 60_000); + }); + + describe('triggerTask with local scope', () => { + it('can manually trigger a task', async () => { + const { manager } = await init('SQLITE_3'); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + initialDelay: Duration.fromObject({ years: 1 }), + fn, + scope: 'local', + }); + + await manager.triggerTask('task1'); + jest.advanceTimersByTime(5000); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, 60_000); + + it('cant trigger a non-existent task', async () => { + const { manager } = await init('SQLITE_3'); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + fn, + scope: 'local', + }); + + await expect(() => manager.triggerTask('task2')).rejects.toThrow( + NotFoundError, + ); + }, 60_000); + + it('cant trigger a running task', async () => { + const { manager } = await init('SQLITE_3'); + + const { promise, resolve } = defer(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromObject({ years: 1 }), + fn: async () => { + resolve(); + await new Promise(r => setTimeout(r, 20000)); + }, + scope: 'local', + }); + + await promise; + await expect(() => manager.triggerTask('task1')).rejects.toThrow( + ConflictError, + ); + }, 60_000); + }); + + // This is just to test the wrapper code; most of the actual tests are in + // TaskWorker.test.ts + describe('createScheduledTaskRunner', () => { + it.each(databases.eachSupportedId())( + 'can run the happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + const promise = new Promise(resolve => fn.mockImplementation(resolve)); + await manager + .createScheduledTaskRunner({ + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + scope: 'global', + }) + .run({ + id: 'task1', + fn, + }); + + await promise; + expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); + }, + ); + }); + + describe('can fetch task ids', () => { + it.each(databases.eachSupportedId())( + 'can fetch both global and local task ids, %p', + async databaseId => { + const { manager } = await init(databaseId); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + scope: 'global', + }); + + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + fn, + scope: 'local', + }); + + await expect(manager.getScheduledTasks()).resolves.toEqual([ + { + id: 'task1', + scope: 'global', + settings: expect.objectContaining({ cadence: 'PT5S' }), + }, + { + id: 'task2', + scope: 'local', + settings: expect.objectContaining({ cadence: 'PT5S' }), + }, + ]); + }, + ); + }); + + describe('parseDuration', () => { + it('should parse durations', () => { + expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S'); + expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S'); + expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *'); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts new file mode 100644 index 0000000000..62b36e024c --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LoggerService, + SchedulerService, + SchedulerServiceTaskDescriptor, + SchedulerServiceTaskFunction, + SchedulerServiceTaskInvocationDefinition, + SchedulerServiceTaskRunner, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +import { Counter, Histogram, metrics } from '@opentelemetry/api'; +import { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { LocalTaskWorker } from './LocalTaskWorker'; +import { TaskWorker } from './TaskWorker'; +import { TaskSettingsV2 } from './types'; +import { validateId } from './util'; + +/** + * Implements the actual task management. + */ +export class PluginTaskSchedulerImpl implements SchedulerService { + private readonly localTasksById = new Map(); + private readonly allScheduledTasks: SchedulerServiceTaskDescriptor[] = []; + + private readonly counter: Counter; + private readonly duration: Histogram; + + constructor( + private readonly databaseFactory: () => Promise, + private readonly logger: LoggerService, + ) { + const meter = metrics.getMeter('default'); + this.counter = meter.createCounter('backend_tasks.task.runs.count', { + description: 'Total number of times a task has been run', + }); + this.duration = meter.createHistogram('backend_tasks.task.runs.duration', { + description: 'Histogram of task run durations', + unit: 'seconds', + }); + } + + async triggerTask(id: string): Promise { + const localTask = this.localTasksById.get(id); + if (localTask) { + localTask.trigger(); + return; + } + + const knex = await this.databaseFactory(); + await TaskWorker.trigger(knex, id); + } + + async scheduleTask( + task: SchedulerServiceTaskScheduleDefinition & + SchedulerServiceTaskInvocationDefinition, + ): Promise { + validateId(task.id); + const scope = task.scope ?? 'global'; + + const settings: TaskSettingsV2 = { + version: 2, + cadence: parseDuration(task.frequency), + initialDelayDuration: + task.initialDelay && parseDuration(task.initialDelay), + timeoutAfterDuration: parseDuration(task.timeout), + }; + + if (scope === 'global') { + const knex = await this.databaseFactory(); + const worker = new TaskWorker( + task.id, + this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }), + knex, + this.logger.child({ task: task.id }), + ); + await worker.start(settings, { signal: task.signal }); + } else { + const worker = new LocalTaskWorker( + task.id, + this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }), + this.logger.child({ task: task.id }), + ); + worker.start(settings, { signal: task.signal }); + this.localTasksById.set(task.id, worker); + } + + this.allScheduledTasks.push({ + id: task.id, + scope: scope, + settings: settings, + }); + } + + createScheduledTaskRunner( + schedule: SchedulerServiceTaskScheduleDefinition, + ): SchedulerServiceTaskRunner { + return { + run: async task => { + await this.scheduleTask({ ...task, ...schedule }); + }, + }; + } + + async getScheduledTasks(): Promise { + return this.allScheduledTasks; + } + + private wrapInMetrics( + fn: SchedulerServiceTaskFunction, + opts: { labels: Record }, + ): SchedulerServiceTaskFunction { + return async abort => { + const labels = { + ...opts.labels, + }; + this.counter.add(1, { ...labels, result: 'started' }); + + const startTime = process.hrtime(); + + try { + await fn(abort); + labels.result = 'completed'; + } catch (ex) { + labels.result = 'failed'; + throw ex; + } finally { + const delta = process.hrtime(startTime); + const endTime = delta[0] + delta[1] / 1e9; + this.counter.add(1, labels); + this.duration.record(endTime, labels); + } + }; + } +} + +export function parseDuration( + frequency: SchedulerServiceTaskScheduleDefinition['frequency'], +): string { + if ('cron' in frequency) { + return frequency.cron; + } + + const parsed = Duration.isDuration(frequency) + ? frequency + : Duration.fromObject(frequency); + + if (!parsed.isValid) { + throw new Error( + `Invalid duration, ${parsed.invalidReason}: ${parsed.invalidExplanation}`, + ); + } + + return parsed.toISO()!; +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.test.ts new file mode 100644 index 0000000000..82133a9e54 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables'; +import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor'; +import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal'; + +const insertTask = async (knex: Knex, task: DbTasksRow) => { + return knex(DB_TASKS_TABLE) + .insert(task) + .onConflict('id') + .merge(['settings_json']); +}; + +const getTask = async (knex: Knex): Promise => { + return (await knex(DB_TASKS_TABLE))[0]; +}; + +describe('PluginTaskSchedulerJanitor', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create({ + ids: [ + /* 'MYSQL_8' not supported yet */ + 'POSTGRES_16', + 'POSTGRES_12', + 'SQLITE_3', + 'MYSQL_8', + ], + }); + const testScopedSignal = createTestScopedSignal(); + + jest.setTimeout(60_000); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each(databases.eachSupportedId())( + 'Should update date if current_run_expires_at expires, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const dateYesterday = new Date( + new Date().setDate(new Date().getDate() - 1), + ); + + await insertTask(knex, { + id: 'task1', + settings_json: '', + next_run_start_at: new Date('2023-03-01 00:00:00'), + current_run_ticket: 'ticket', + current_run_started_at: dateYesterday, + current_run_expires_at: dateYesterday, + }); + + const worker = new PluginTaskSchedulerJanitor({ + waitBetweenRuns: Duration.fromObject({ milliseconds: 20 }), + knex, + logger, + }); + + worker.start(testScopedSignal()); + + await waitForExpect(async () => { + await expect(getTask(knex)).resolves.toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + }); + }, + ); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.ts new file mode 100644 index 0000000000..b0cd4572cf --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerJanitor.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables'; +import { sleep } from './util'; + +/** + * Makes sure to auto-expire and clean up things that time out or for other + * reasons should not be left lingering. + */ +export class PluginTaskSchedulerJanitor { + private readonly knex: Knex; + private readonly waitBetweenRuns: Duration; + private readonly logger: LoggerService; + + constructor(options: { + knex: Knex; + waitBetweenRuns: Duration; + logger: LoggerService; + }) { + this.knex = options.knex; + this.waitBetweenRuns = options.waitBetweenRuns; + this.logger = options.logger; + } + + async start(abortSignal?: AbortSignal) { + while (!abortSignal?.aborted) { + try { + await this.runOnce(); + } catch (e) { + this.logger.warn(`Error while performing janitorial tasks, ${e}`); + } + + await sleep(this.waitBetweenRuns, abortSignal); + } + } + + private async runOnce() { + const dbNull = this.knex.raw('null'); + const configClient = this.knex.client.config.client; + + let tasks: Array<{ id: string }>; + if (configClient.includes('sqlite3') || configClient.includes('mysql')) { + tasks = await this.knex(DB_TASKS_TABLE) + .select('id') + .where('current_run_expires_at', '<', this.knex.fn.now()); + await this.knex(DB_TASKS_TABLE) + .whereIn( + 'id', + tasks.map(t => t.id), + ) + .update({ + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }); + } else { + tasks = await this.knex(DB_TASKS_TABLE) + .where('current_run_expires_at', '<', this.knex.fn.now()) + .update({ + current_run_ticket: dbNull, + current_run_started_at: dbNull, + current_run_expires_at: dbNull, + }) + .returning(['id']); + } + + // In rare cases, knex drivers may ignore "returning", and return the number + // of rows changed instead + if (typeof tasks === 'number') { + if (tasks > 0) { + this.logger.warn(`${tasks} tasks timed out and were lost`); + } + } else { + for (const { id } of tasks) { + this.logger.warn(`Task timed out and was lost: ${id}`); + } + } + } +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.test.ts new file mode 100644 index 0000000000..d286eaee38 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2021 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 { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { TaskScheduler } from './TaskScheduler'; +import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal'; + +jest.setTimeout(60_000); + +describe('TaskScheduler', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create(); + const testScopedSignal = createTestScopedSignal(); + + async function createDatabase( + databaseId: TestDatabaseId, + ): Promise { + const knex = await databases.init(databaseId); + const databaseManager: Partial = { + forPlugin: () => ({ + getClient: async () => knex, + }), + }; + return databaseManager as DatabaseManager; + } + + it.each(databases.eachSupportedId())( + 'can return a working v1 plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task1', + timeout: Duration.fromMillis(5000), + frequency: Duration.fromMillis(5000), + signal: testScopedSignal(), + fn, + }); + + await waitForExpect(() => { + expect(fn).toHaveBeenCalled(); + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'can return a working v2 plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: { cron: '* * * * * *' }, + signal: testScopedSignal(), + fn, + }); + + await waitForExpect(() => { + expect(fn).toHaveBeenCalled(); + }); + }, + ); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.ts new file mode 100644 index 0000000000..21a19ce700 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskScheduler.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2021 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 { + DatabaseManager, + getRootLogger, + LegacyRootDatabaseService, + PluginDatabaseManager, +} from '@backstage/backend-common'; +import { + LoggerService, + RootConfigService, + SchedulerService, +} from '@backstage/backend-plugin-api'; +import { once } from 'lodash'; +import { Duration } from 'luxon'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl'; +import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor'; + +/** + * Deals with the scheduling of distributed tasks. + */ +export class TaskScheduler { + static fromConfig( + config: RootConfigService, + options?: { + databaseManager?: LegacyRootDatabaseService; + logger?: LoggerService; + }, + ): TaskScheduler { + const databaseManager = + options?.databaseManager ?? DatabaseManager.fromConfig(config); + const logger = (options?.logger || getRootLogger()).child({ + type: 'taskManager', + }); + return new TaskScheduler(databaseManager, logger); + } + + constructor( + private readonly databaseManager: LegacyRootDatabaseService, + private readonly logger: LoggerService, + ) {} + + /** + * Instantiates a task manager instance for the given plugin. + * + * @param pluginId - The unique ID of the plugin, for example "catalog" + * @returns A {@link SchedulerService} instance + */ + forPlugin(pluginId: string): SchedulerService { + return TaskScheduler.forPlugin({ + pluginId, + databaseManager: this.databaseManager.forPlugin(pluginId), + logger: this.logger, + }); + } + + static forPlugin(opts: { + pluginId: string; + databaseManager: PluginDatabaseManager; + logger: LoggerService; + }): SchedulerService { + const databaseFactory = once(async () => { + const knex = await opts.databaseManager.getClient(); + + if (!opts.databaseManager.migrations?.skip) { + await migrateBackendTasks(knex); + } + + if (process.env.NODE_ENV !== 'test') { + const janitor = new PluginTaskSchedulerJanitor({ + knex, + waitBetweenRuns: Duration.fromObject({ minutes: 1 }), + logger: opts.logger, + }); + janitor.start(); + } + + return knex; + }); + + return new PluginTaskSchedulerImpl(databaseFactory, opts.logger); + } +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.test.ts new file mode 100644 index 0000000000..20d9b63859 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.test.ts @@ -0,0 +1,507 @@ +/* + * Copyright 2021 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 { getVoidLogger } from '@backstage/backend-common'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { DateTime, Duration } from 'luxon'; +import waitForExpect from 'wait-for-expect'; +import { migrateBackendTasks } from '../database/migrateBackendTasks'; +import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables'; +import { TaskWorker } from './TaskWorker'; +import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal'; +import { TaskSettingsV2 } from './types'; + +jest.setTimeout(60_000); + +describe('TaskWorker', () => { + const logger = getVoidLogger(); + const databases = TestDatabases.create(); + const testScopedSignal = createTestScopedSignal(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each(databases.eachSupportedId())( + 'goes through the expected states, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV2 = { + version: 2, + cadence: '*/2 * * * * *', + initialDelayDuration: Duration.fromObject({ seconds: 1 }).toISO()!, + timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!, + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + expect(JSON.parse(row.settings_json)).toEqual({ + version: 2, + cadence: '*/2 * * * * *', + initialDelayDuration: 'PT1S', + timeoutAfterDuration: 'PT1M', + }); + + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'not-ready-yet', + }); + + await waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + }); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + true, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: null, + current_run_started_at: null, + current_run_expires_at: null, + }), + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'logs error when the task throws, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + jest.spyOn(logger, 'error'); + const fn = jest.fn().mockRejectedValue(new Error('failed')); + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', + timeoutAfterDuration: Duration.fromMillis(60000).toISO()!, + }; + const checkFrequency = Duration.fromObject({ milliseconds: 100 }); + const worker = new TaskWorker('task1', fn, knex, logger, checkFrequency); + worker.start(settings, { signal: testScopedSignal() }); + + await waitForExpect(() => { + expect(logger.error).toHaveBeenCalled(); + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'runs tasks more than once even when the task throws, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn().mockRejectedValue(new Error('failed')); + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', + timeoutAfterDuration: Duration.fromMillis(60000).toISO()!, + }; + const checkFrequency = Duration.fromObject({ milliseconds: 100 }); + const worker = new TaskWorker('task1', fn, knex, logger, checkFrequency); + worker.start(settings, { signal: testScopedSignal() }); + + await waitForExpect(() => { + expect(fn).toHaveBeenCalledTimes(3); + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'does not clobber ticket lock when stolen, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', + timeoutAfterDuration: Duration.fromMillis(60000).toISO()!, + }; + + const worker = new TaskWorker('task1', fn, knex, logger); + await worker.persistTask(settings); + + await waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + }); + + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); + + let row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'ticket', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + + await knex(DB_TASKS_TABLE) + .where('id', '=', 'task1') + .update({ current_run_ticket: 'stolen' }); + + await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + + row = (await knex(DB_TASKS_TABLE))[0]; + expect(row).toEqual( + expect.objectContaining({ + id: 'task1', + current_run_ticket: 'stolen', + current_run_started_at: expect.anything(), + current_run_expires_at: expect.anything(), + }), + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'gracefully handles a disappeared task row, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn(async () => {}); + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', + timeoutAfterDuration: Duration.fromMillis(60000).toISO()!, + }; + + const worker1 = new TaskWorker('task1', fn, knex, logger); + await worker1.persistTask(settings); + await knex(DB_TASKS_TABLE).where('id', '=', 'task1').delete(); + await expect(worker1.findReadyTask()).resolves.toEqual({ + result: 'abort', + }); + + const worker2 = new TaskWorker('task2', fn, knex, logger); + await worker2.persistTask(settings); + + await waitForExpect(async () => { + await expect(worker2.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + }); + + await knex(DB_TASKS_TABLE).where('id', '=', 'task2').delete(); + await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe( + false, + ); + + const worker3 = new TaskWorker('task3', fn, knex, logger); + await worker3.persistTask(settings); + + await waitForExpect(async () => { + await expect(worker3.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); + }); + + await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe( + true, + ); + await knex(DB_TASKS_TABLE).where('id', '=', 'task3').delete(); + await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe( + false, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'respects initialDelayDuration per worker, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const abortFirst = new AbortController(); + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: 'PT0.3S', + cadence: 'PT0.1S', + timeoutAfterDuration: 'PT10S', + }; + + // Start a single worker and make sure it waits and then goes to work + const fn1 = jest.fn(async () => {}); + const worker1 = new TaskWorker( + 'task1', + fn1, + knex, + logger, + Duration.fromMillis(10), + ); + await worker1.start(settings, { signal: abortFirst.signal }); + + expect(fn1).toHaveBeenCalledTimes(0); + await new Promise(resolve => setTimeout(resolve, 250)); + expect(fn1).toHaveBeenCalledTimes(0); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(fn1.mock.calls.length).toBeGreaterThan(0); + + // Start a second worker and make sure it waits but the first worker still works along + const fn2 = jest.fn(); + const promise2 = new Promise(resolve => fn2.mockImplementation(resolve)); + const worker2 = new TaskWorker( + 'task1', + fn2, + knex, + logger, + Duration.fromMillis(10), + ); + await worker2.start(settings, { signal: testScopedSignal() }); + + // We eventually abort the first worker just to make sure that the second + // one for sure will get a go at running the task + setTimeout(() => abortFirst.abort(), 1000); + + const before = fn1.mock.calls.length; + await promise2; + expect(fn1.mock.calls.length).toBeGreaterThan(before); + + await knex.destroy(); + }, + ); + + it.each(databases.eachSupportedId())( + 'next_run_start_at is always the min between schedule changes from cron frequency, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + const settings: TaskSettingsV2 = { + version: 2, + cadence: '*/15 * * * *', + initialDelayDuration: 'PT2M', + timeoutAfterDuration: 'PT1M', + }; + + const worker = new TaskWorker('task99', fn, knex, logger); + await worker.persistTask(settings); + const row1 = (await knex(DB_TASKS_TABLE))[0]; + + const settings2 = { + ...settings, + cadence: '*/2 * * * *', + initialDelayDuration: 'PT1M', + }; + await worker.persistTask(settings2); + const row2 = (await knex(DB_TASKS_TABLE))[0]; + + expect(row2.next_run_start_at).not.toStrictEqual(row1.next_run_start_at); + + const settings3 = { ...settings }; + await worker.persistTask(settings3); + const row3 = (await knex(DB_TASKS_TABLE))[0]; + + // The new timestamp can basically be 0 or a minute depending on how the + // initialDelayDuration falls right on a cron boundary. This kinda + // contrived check removes a test flakiness based on wall clock time. + expect( + Math.abs( + +new Date(row3.next_run_start_at) - +new Date(row2.next_run_start_at), + ), + ).toBeLessThanOrEqual(60_000); + + await knex.destroy(); + }, + ); + + it.each(databases.eachSupportedId())( + 'next_run_start_at is always the min between schedule changes when using human duration frequency, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + + const initialSettings: TaskSettingsV2 = { + version: 2, + cadence: 'PT120M', + timeoutAfterDuration: 'PT1M', + }; + + const worker = new TaskWorker('task99', fn, knex, logger); + await worker.persistTask(initialSettings); + // replicate task running, sets next_run_start_at based on cadence + await worker.tryClaimTask('ticket', initialSettings); + await worker.tryReleaseTask('ticket', initialSettings); + + // grab initial row for comparisons later + const rowAfterClaimAndRelease = ( + await knex(DB_TASKS_TABLE) + )[0]; + + const settings: TaskSettingsV2 = { + ...initialSettings, + cadence: 'PT60M', + }; + await worker.persistTask(settings); + const row1 = (await knex(DB_TASKS_TABLE))[0]; + + const rowAfterClaimAndReleaseNextStartAt = DateTime.fromJSDate( + new Date(rowAfterClaimAndRelease.next_run_start_at), + ); + const row1NextStartAt = DateTime.fromJSDate( + new Date(row1.next_run_start_at), + ); + const now = DateTime.now(); + expect( + rowAfterClaimAndReleaseNextStartAt.diff(row1NextStartAt).as('minutes'), + ).toBeCloseTo(60, 1); // ensure that next start at is sooner than initial by one hour + expect(row1NextStartAt.diff(now).as('minutes')).toBeCloseTo(60, 1); // ensure that next start at is later than now by one hour + expect( + rowAfterClaimAndReleaseNextStartAt.diff(now).as('minutes'), + ).toBeCloseTo(120, 1); + + const settings2 = { + ...settings, + }; + await worker.persistTask(settings2); + const row2 = (await knex(DB_TASKS_TABLE))[0]; + + expect(row2.next_run_start_at).toStrictEqual(row1.next_run_start_at); + + await knex.destroy(); + }, + ); + + it.each(databases.eachSupportedId())( + 'next_run_start_at is always the min between schedule changes when using human duration frequency with initial start delay, %p', + async databaseId => { + const knex = await databases.init(databaseId); + await migrateBackendTasks(knex); + + const fn = jest.fn( + async () => new Promise(resolve => setTimeout(resolve, 50)), + ); + + const initialSettings: TaskSettingsV2 = { + version: 2, + cadence: 'PT120M', + initialDelayDuration: 'PT2M', + timeoutAfterDuration: 'PT1M', + }; + + const worker = new TaskWorker('task99', fn, knex, logger); + await worker.persistTask(initialSettings); + // replicate task running, sets next_run_start_at based on cadence + await worker.tryClaimTask('ticket', initialSettings); + await worker.tryReleaseTask('ticket', initialSettings); + + // grab initial row for comparisons later + const rowAfterClaimAndRelease = ( + await knex(DB_TASKS_TABLE) + )[0]; + + const settings: TaskSettingsV2 = { + ...initialSettings, + cadence: 'PT60M', + }; + await worker.persistTask(settings); + const row1 = (await knex(DB_TASKS_TABLE))[0]; + + const rowAfterClaimAndReleaseNextStartAt = DateTime.fromJSDate( + new Date(rowAfterClaimAndRelease.next_run_start_at), + ); + const row1NextStartAt = DateTime.fromJSDate( + new Date(row1.next_run_start_at), + ); + const now = DateTime.now(); + expect( + rowAfterClaimAndReleaseNextStartAt.diff(row1NextStartAt).as('minutes'), + ).toBeCloseTo(62, 1); // ensure that next start at is sooner than initial by one hour, plus the 2 minute delay (set my tryReleaseTask) + expect(row1NextStartAt.diff(now).as('minutes')).toBeCloseTo(60, 1); // ensure that next start at is later than now by one hour (2 minute delay doesn't take effect here) + expect( + rowAfterClaimAndReleaseNextStartAt.diff(now).as('minutes'), + ).toBeCloseTo(122, 1); // includes 2 minute start delay (which is persisted from tryReleaseTask) + + const settings2 = { + ...settings, + }; + await worker.persistTask(settings2); + const row2 = (await knex(DB_TASKS_TABLE))[0]; + + expect(row2.next_run_start_at).toStrictEqual(row1.next_run_start_at); + + await knex.destroy(); + }, + ); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts new file mode 100644 index 0000000000..a1c4ec44fd --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts @@ -0,0 +1,373 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LoggerService } from '@backstage/backend-plugin-api'; +import { ConflictError, NotFoundError } from '@backstage/errors'; +import { CronTime } from 'cron'; +import { Knex } from 'knex'; +import { DateTime, Duration } from 'luxon'; +import { v4 as uuid } from 'uuid'; +import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables'; +import { TaskSettingsV2, taskSettingsV2Schema } from './types'; +import { delegateAbortController, nowPlus, sleep } from './util'; +import { SchedulerServiceTaskFunction } from '@backstage/backend-plugin-api'; + +const DEFAULT_WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 }); + +/** + * Implements tasks that run across worker hosts, with collaborative locking. + * + * @private + */ +export class TaskWorker { + constructor( + private readonly taskId: string, + private readonly fn: SchedulerServiceTaskFunction, + private readonly knex: Knex, + private readonly logger: LoggerService, + private readonly workCheckFrequency: Duration = DEFAULT_WORK_CHECK_FREQUENCY, + ) {} + + async start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) { + try { + await this.persistTask(settings); + } catch (e) { + throw new Error(`Failed to persist task, ${e}`); + } + + this.logger.info( + `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, + ); + + let workCheckFrequency = this.workCheckFrequency; + const isCron = !settings?.cadence.startsWith('P'); + if (!isCron) { + const cadence = Duration.fromISO(settings.cadence); + if (cadence < workCheckFrequency) { + workCheckFrequency = cadence; + } + } + + let attemptNum = 1; + (async () => { + for (;;) { + try { + if (settings.initialDelayDuration) { + await sleep( + Duration.fromISO(settings.initialDelayDuration), + options?.signal, + ); + } + + while (!options?.signal?.aborted) { + const runResult = await this.runOnce(options?.signal); + + if (runResult.result === 'abort') { + break; + } + + await sleep(workCheckFrequency, options?.signal); + } + + this.logger.info(`Task worker finished: ${this.taskId}`); + attemptNum = 0; + break; + } catch (e) { + attemptNum += 1; + this.logger.warn( + `Task worker failed unexpectedly, attempt number ${attemptNum}, ${e}`, + ); + await sleep(Duration.fromObject({ seconds: 1 })); + } + } + })(); + } + + static async trigger(knex: Knex, taskId: string): Promise { + // check if task exists + const rows = await knex(DB_TASKS_TABLE) + .select(knex.raw(1)) + .where('id', '=', taskId); + if (rows.length !== 1) { + throw new NotFoundError(`Task ${taskId} does not exist`); + } + + const updatedRows = await knex(DB_TASKS_TABLE) + .where('id', '=', taskId) + .whereNull('current_run_ticket') + .update({ + next_run_start_at: knex.fn.now(), + }); + if (updatedRows < 1) { + throw new ConflictError(`Task ${taskId} is currently running`); + } + } + + /** + * Makes a single attempt at running the task to completion, if ready. + * + * @returns The outcome of the attempt + */ + private async runOnce( + signal?: AbortSignal, + ): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'failed' } + | { result: 'completed' } + > { + const findResult = await this.findReadyTask(); + if ( + findResult.result === 'not-ready-yet' || + findResult.result === 'abort' + ) { + return findResult; + } + + const taskSettings = findResult.settings; + const ticket = uuid(); + + const claimed = await this.tryClaimTask(ticket, taskSettings); + if (!claimed) { + return { result: 'not-ready-yet' }; + } + + // Abort the task execution either if the worker is stopped, or if the + // task timeout is hit + const taskAbortController = delegateAbortController(signal); + const timeoutHandle = setTimeout(() => { + taskAbortController.abort(); + }, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds')); + + try { + await this.fn(taskAbortController.signal); + taskAbortController.abort(); // releases resources + } catch (e) { + this.logger.error(e); + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'failed' }; + } finally { + clearTimeout(timeoutHandle); + } + + await this.tryReleaseTask(ticket, taskSettings); + return { result: 'completed' }; + } + + /** + * Perform the initial store of the task info + */ + async persistTask(settings: TaskSettingsV2) { + // Perform an initial parse to ensure that we will definitely be able to + // read it back again. + taskSettingsV2Schema.parse(settings); + + const isCron = !settings?.cadence.startsWith('P'); + + let startAt: Knex.Raw | undefined; + let nextStartAt: Knex.Raw | undefined; + if (settings.initialDelayDuration) { + startAt = nowPlus( + Duration.fromISO(settings.initialDelayDuration), + this.knex, + ); + } + + if (isCron) { + const time = new CronTime(settings.cadence) + .sendAt() + .minus({ seconds: 1 }) // immediately, if "* * * * * *" + .toUTC(); + + nextStartAt = this.nextRunAtRaw(time); + startAt ||= nextStartAt; + } else { + startAt ||= this.knex.fn.now(); + nextStartAt = nowPlus(Duration.fromISO(settings.cadence), this.knex); + } + + this.logger.debug(`task: ${this.taskId} configured to run at: ${startAt}`); + + // It's OK if the task already exists; if it does, just replace its + // settings with the new value and start the loop as usual. + const settingsJson = JSON.stringify(settings); + await this.knex(DB_TASKS_TABLE) + .insert({ + id: this.taskId, + settings_json: settingsJson, + next_run_start_at: startAt, + }) + .onConflict('id') + .merge( + this.knex.client.config.client.includes('mysql') + ? { + settings_json: settingsJson, + next_run_start_at: this.knex.raw( + `CASE WHEN ?? < ?? THEN ?? ELSE ?? END`, + [ + nextStartAt, + 'next_run_start_at', + nextStartAt, + 'next_run_start_at', + ], + ), + } + : { + settings_json: this.knex.ref('excluded.settings_json'), + next_run_start_at: this.knex.raw( + `CASE WHEN ?? < ?? THEN ?? ELSE ?? END`, + [ + nextStartAt, + `${DB_TASKS_TABLE}.next_run_start_at`, + nextStartAt, + `${DB_TASKS_TABLE}.next_run_start_at`, + ], + ), + }, + ); + } + + /** + * Check if the task is ready to run + */ + async findReadyTask(): Promise< + | { result: 'not-ready-yet' } + | { result: 'abort' } + | { result: 'ready'; settings: TaskSettingsV2 } + > { + const [row] = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .select({ + settingsJson: 'settings_json', + ready: this.knex.raw( + `CASE + WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE + ELSE FALSE + END`, + [this.knex.fn.now()], + ), + }); + + if (!row) { + this.logger.info( + 'No longer able to find task; aborting and assuming that it has been unregistered or expired', + ); + return { result: 'abort' }; + } else if (!row.ready) { + return { result: 'not-ready-yet' }; + } + + try { + const obj = JSON.parse(row.settingsJson); + const settings = taskSettingsV2Schema.parse(obj); + return { result: 'ready', settings }; + } catch (e) { + this.logger.info( + `Task "${this.taskId}" is no longer able to parse task settings; aborting and assuming that a ` + + `newer version of the task has been issued and being handled by other workers, ${e}`, + ); + return { result: 'abort' }; + } + } + + /** + * Attempts to claim a task that's ready for execution, on this worker's + * behalf. We should not attempt to perform the work unless the claim really + * goes through. + * + * @param ticket - A globally unique string that changes for each invocation + * @param settings - The settings of the task to claim + * @returns True if it was successfully claimed + */ + async tryClaimTask( + ticket: string, + settings: TaskSettingsV2, + ): Promise { + const startedAt = this.knex.fn.now(); + const expiresAt = settings.timeoutAfterDuration + ? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex) + : this.knex.raw('null'); + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .whereNull('current_run_ticket') + .update({ + current_run_ticket: ticket, + current_run_started_at: startedAt, + current_run_expires_at: expiresAt, + }); + + return rows === 1; + } + + async tryReleaseTask( + ticket: string, + settings: TaskSettingsV2, + ): Promise { + const isCron = !settings?.cadence.startsWith('P'); + + let nextRun: Knex.Raw; + if (isCron) { + const time = new CronTime(settings.cadence).sendAt().toUTC(); + this.logger.debug(`task: ${this.taskId} will next occur around ${time}`); + + nextRun = this.nextRunAtRaw(time); + } else { + const dt = Duration.fromISO(settings.cadence).as('seconds'); + this.logger.debug( + `task: ${this.taskId} will next occur around ${DateTime.now().plus({ + seconds: dt, + })}`, + ); + + if (this.knex.client.config.client.includes('sqlite3')) { + nextRun = this.knex.raw( + `max(datetime(next_run_start_at, ?), datetime('now'))`, + [`+${dt} seconds`], + ); + } else if (this.knex.client.config.client.includes('mysql')) { + nextRun = this.knex.raw( + `greatest(next_run_start_at + interval ${dt} second, now())`, + ); + } else { + nextRun = this.knex.raw( + `greatest(next_run_start_at + interval '${dt} seconds', now())`, + ); + } + } + + const rows = await this.knex(DB_TASKS_TABLE) + .where('id', '=', this.taskId) + .where('current_run_ticket', '=', ticket) + .update({ + next_run_start_at: nextRun, + current_run_ticket: this.knex.raw('null'), + current_run_started_at: this.knex.raw('null'), + current_run_expires_at: this.knex.raw('null'), + }); + + return rows === 1; + } + + private nextRunAtRaw(time: DateTime): Knex.Raw { + if (this.knex.client.config.client.includes('sqlite3')) { + return this.knex.raw('datetime(?)', [time.toISO()]); + } else if (this.knex.client.config.client.includes('mysql')) { + return this.knex.raw(`?`, [time.toSQL({ includeOffset: false })]); + } + return this.knex.raw(`?`, [time.toISO()]); + } +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/__testUtils__/createTestScopedSignal.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/__testUtils__/createTestScopedSignal.ts new file mode 100644 index 0000000000..6a497ea266 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/__testUtils__/createTestScopedSignal.ts @@ -0,0 +1,28 @@ +/* + * 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 function createTestScopedSignal(): () => AbortSignal { + let testAbortController = new AbortController(); + + beforeEach(() => { + testAbortController = new AbortController(); + }); + afterEach(() => { + testAbortController.abort(); + }); + + return () => testAbortController.signal; +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/types.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/types.ts new file mode 100644 index 0000000000..61e6c1d9a8 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2021 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 { CronTime } from 'cron'; +import { Duration } from 'luxon'; +import { z } from 'zod'; + +function isValidOptionalDurationString(d: string | undefined): boolean { + try { + return !d || Duration.fromISO(d).isValid; + } catch { + return false; + } +} + +function isValidCronFormat(c: string | undefined): boolean { + try { + if (!c) { + return false; + } + // parse cron format to ensure it's a valid format. + // eslint-disable-next-line no-new + new CronTime(c); + return true; + } catch { + return false; + } +} + +export const taskSettingsV1Schema = z.object({ + version: z.literal(1), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + recurringAtMostEveryDuration: z + .string() + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), +}); + +/** + * The properties that control a scheduled task (version 1). + */ +export type TaskSettingsV1 = z.infer; + +export const taskSettingsV2Schema = z.object({ + version: z.literal(2), + cadence: z + .string() + .refine(isValidCronFormat, { message: 'Invalid cron' }) + .or( + z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + ), + timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), +}); + +/** + * The properties that control a scheduled task (version 2). + */ +export type TaskSettingsV2 = z.infer; diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts new file mode 100644 index 0000000000..e2536abb88 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 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 knexFactory, { Knex } from 'knex'; +import { Duration } from 'luxon'; +import { delegateAbortController, nowPlus, sleep, validateId } from './util'; + +class KnexBuilder { + public build(client: string): Knex { + return knexFactory({ client, useNullAsDefault: true }); + } +} + +describe('util', () => { + describe('validateId', () => { + it.each(['a', 'a_b', 'ab123c_2', 'a!', 'A', 'a-b', 'a.b', '_a', 'a_'])( + 'accepts valid inputs, %p', + async input => { + expect(validateId(input)).toBeUndefined(); + }, + ); + + it.each(['', null, Symbol('a')])( + 'rejects invalid inputs, %p', + async input => { + expect(() => validateId(input as any)).toThrow(); + }, + ); + }); + + describe('sleep', () => { + it('finishes the wait as expected with no signal', async () => { + const ac = new AbortController(); + const start = Date.now(); + await sleep(Duration.fromObject({ seconds: 1 }), ac.signal); + expect(Date.now() - start).toBeGreaterThan(800); + }, 5_000); + + it('aborts properly on the signal', async () => { + const ac = new AbortController(); + const promise = sleep(Duration.fromObject({ seconds: 10 }), ac.signal); + ac.abort(); + await promise; + expect(true).toBe(true); + }, 1_000); + }); + + describe('delegateAbortController', () => { + it('inherits parent abort state', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + parent.abort(); + expect(parent.signal.aborted).toBe(true); + expect(child.signal.aborted).toBe(true); + }); + + it('does not inherit from child to parent', () => { + const parent = new AbortController(); + const child = delegateAbortController(parent.signal); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(false); + child.abort(); + expect(parent.signal.aborted).toBe(false); + expect(child.signal.aborted).toBe(true); + }); + }); + + describe('nowPlus', () => { + describe('without duration', () => { + const databases = [ + { client: 'sqlite3', expected: 'CURRENT_TIMESTAMP' }, + { client: 'mysql2', expected: 'CURRENT_TIMESTAMP' }, + { client: 'pg', expected: 'CURRENT_TIMESTAMP' }, + ]; + + it.each(databases)('for client $client', ({ client, expected }) => { + const knex = new KnexBuilder().build(client); + const result = nowPlus(undefined, knex); + + expect(result.toString()).toBe(expected); + }); + }); + describe('With duration', () => { + const databases = [ + { client: 'sqlite3', expected: "datetime('now', '20 seconds')" }, + { client: 'mysql2', expected: 'now() + interval 20 second' }, + { client: 'pg', expected: "now() + interval '20 seconds'" }, + ]; + it.each(databases)('for client $client', ({ client, expected }) => { + const duration = Duration.fromObject({ seconds: 20 }); + const knex = new KnexBuilder().build(client); + const result = nowPlus(duration, knex); + + expect(result.toString()).toBe(expected); + }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts new file mode 100644 index 0000000000..70d67a9fbe --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2021 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 { InputError } from '@backstage/errors'; +import { Knex } from 'knex'; +import { DateTime, Duration } from 'luxon'; + +// Keep the IDs compatible with e.g. Prometheus labels +export function validateId(id: string) { + if (typeof id !== 'string' || !id.trim()) { + throw new InputError(`${id} is not a valid ID, expected non-empty string`); + } +} + +export function dbTime(t: Date | string): DateTime { + if (typeof t === 'string') { + return DateTime.fromSQL(t); + } + return DateTime.fromJSDate(t); +} + +export function nowPlus(duration: Duration | undefined, knex: Knex) { + const seconds = duration?.as('seconds') ?? 0; + if (!seconds) { + return knex.fn.now(); + } + + if (knex.client.config.client.includes('sqlite3')) { + return knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]); + } + + if (knex.client.config.client.includes('mysql')) { + return knex.raw(`now() + interval ${seconds} second`); + } + + return knex.raw(`now() + interval '${seconds} seconds'`); +} + +/** + * Sleep for the given duration, but return sooner if the abort signal + * triggers. + * + * @param duration - The amount of time to sleep, at most + * @param abortSignal - An optional abort signal that short circuits the wait + */ +export async function sleep( + duration: Duration, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + return; + } + + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + + const done = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + abortSignal?.removeEventListener('abort', done); + resolve(); + }; + + timeoutHandle = setTimeout(done, duration.as('milliseconds')); + abortSignal?.addEventListener('abort', done); + }); +} + +/** + * Creates a new AbortController that, in addition to working as a regular + * standalone controller, also gets aborted if the given parent signal + * reaches aborted state. + * + * @param parent - The "parent" signal that can trigger the delegate + */ +export function delegateAbortController(parent?: AbortSignal): AbortController { + const delegate = new AbortController(); + + if (parent) { + if (parent.aborted) { + delegate.abort(); + } else { + const onParentAborted = () => { + delegate.abort(); + }; + + const onChildAborted = () => { + parent.removeEventListener('abort', onParentAborted); + }; + + parent.addEventListener('abort', onParentAborted, { once: true }); + delegate.signal.addEventListener('abort', onChildAborted, { once: true }); + } + } + + return delegate; +} diff --git a/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.test.ts new file mode 100644 index 0000000000..7b889b3a0e --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.test.ts @@ -0,0 +1,46 @@ +/* + * 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 } from '@backstage/backend-plugin-api'; +import { ServiceFactoryTester } from '@backstage/backend-test-utils'; +import { schedulerServiceFactory } from './schedulerServiceFactory'; + +describe('schedulerFactory', () => { + it('creates sidecar database features', async () => { + const tester = ServiceFactoryTester.from(schedulerServiceFactory()); + + const scheduler = await tester.get(); + await scheduler.scheduleTask({ + id: 'task1', + timeout: { seconds: 1 }, + frequency: { seconds: 1 }, + fn: async () => {}, + }); + + const database = await tester.getService(coreServices.database); + + const client = await database.getClient(); + await expect( + client.from('backstage_backend_tasks__tasks').count(), + ).resolves.toEqual([{ 'count(*)': 1 }]); + await expect( + client.from('backstage_backend_tasks__knex_migrations').count(), + ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); + await expect( + client.from('backstage_backend_tasks__knex_migrations_lock').count(), + ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts new file mode 100644 index 0000000000..55c0d58e7b --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts @@ -0,0 +1,42 @@ +/* + * 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 { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { TaskScheduler } from './lib/TaskScheduler'; + +/** + * The default service factory for {@link @backstage/backend-plugin-api#coreServices.scheduler}. + * + * @public + */ +export const schedulerServiceFactory = createServiceFactory({ + service: coreServices.scheduler, + deps: { + plugin: coreServices.pluginMetadata, + database: coreServices.database, + logger: coreServices.logger, + }, + async factory({ plugin, database, logger }) { + return TaskScheduler.forPlugin({ + pluginId: plugin.getId(), + databaseManager: database, + logger, + }); + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/urlReader/index.ts b/packages/backend-defaults/src/entrypoints/urlReader/index.ts new file mode 100644 index 0000000000..6a4b9f65be --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/urlReader/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 { urlReaderServiceFactory } from './urlReaderServiceFactory'; diff --git a/plugins/devtools-backend/src/run.ts b/packages/backend-defaults/src/entrypoints/urlReader/urlReaderServiceFactory.ts similarity index 53% rename from plugins/devtools-backend/src/run.ts rename to packages/backend-defaults/src/entrypoints/urlReader/urlReaderServiceFactory.ts index d945aa13f0..7f404a24b1 100644 --- a/plugins/devtools-backend/src/run.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/urlReaderServiceFactory.ts @@ -14,20 +14,23 @@ * limitations under the License. */ -import { getRootLogger } from '@backstage/backend-common'; -import yn from 'yn'; -import { startStandaloneServer } from './service/standaloneServer'; +import { UrlReaders } from '@backstage/backend-common'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; -const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); -const logger = getRootLogger(); - -startStandaloneServer({ port, enableCors, logger }).catch(err => { - logger.error(err); - process.exit(1); -}); - -process.on('SIGINT', () => { - logger.info('CTRL+C pressed; exiting.'); - process.exit(0); +/** @public */ +export const urlReaderServiceFactory = createServiceFactory({ + service: coreServices.urlReader, + deps: { + config: coreServices.rootConfig, + logger: coreServices.logger, + }, + async factory({ config, logger }) { + return UrlReaders.default({ + config, + logger, + }); + }, }); diff --git a/plugins/auth-backend/src/run.ts b/packages/backend-defaults/src/setupTests.ts similarity index 62% rename from plugins/auth-backend/src/run.ts rename to packages/backend-defaults/src/setupTests.ts index 7732bbd41a..76619a2542 100644 --- a/plugins/auth-backend/src/run.ts +++ b/packages/backend-defaults/src/setupTests.ts @@ -14,19 +14,12 @@ * limitations under the License. */ -import { getRootLogger } from '@backstage/backend-common'; -import { startStandaloneServer } from './service/standaloneServer'; +import { TestDatabases } from '@backstage/backend-test-utils'; +import { Settings } from 'luxon'; -const logger = getRootLogger(); +// TS still thinks that methods can return null / placeholders, but we still want to throw as soon as possible when things go wrong +Settings.throwOnInvalid = true; -startStandaloneServer({ logger }).catch(err => { - logger.error(err); - process.exit(1); +TestDatabases.setDefaults({ + ids: ['MYSQL_8', 'POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'], }); - -process.on('SIGINT', () => { - logger.info('CTRL+C pressed; exiting.'); - process.exit(0); -}); - -module.hot?.accept(); diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts index 487729687f..340991b11f 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts @@ -59,7 +59,7 @@ describe('backend-dynamic-feature-service', () => { name: string; packageManifest: ScannedPluginManifest; indexFile?: { - retativePath: string[]; + relativePath: string[]; content: string; }; expectedLogs?(location: URL): { @@ -83,7 +83,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: 'exports.dynamicPluginInstaller={ kind: "new", install: () => [] }', }, @@ -127,7 +127,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: `const alpha = { $$type: '@backstage/BackendFeature' }; exports["default"] = alpha;`, }, expectedLogs(location) { @@ -170,7 +170,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: `const alpha = () => { return { $$type: '@backstage/BackendFeature' } }; alpha.$$type = '@backstage/BackendFeatureFactory'; exports["default"] = alpha;`, @@ -215,7 +215,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: 'exports.dynamicPluginInstaller={ kind: "new", install: () => [] }', }, @@ -262,7 +262,7 @@ describe('backend-dynamic-feature-service', () => { return { errors: [ { - message: `an error occured while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`, + message: `an error occurred while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`, meta: { name: 'Error', message: expect.stringContaining( @@ -305,7 +305,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: '', }, expectedLogs(location) { @@ -332,7 +332,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: 'exports.dynamicPluginInstaller={ something: "else", unexpectedMethod() {} }', }, @@ -360,14 +360,14 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: 'strange text with syntax error', }, expectedLogs(location) { return { errors: [ { - message: `an error occured while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`, + message: `an error occurred while loading dynamic backend plugin 'backend-dynamic-plugin-test' from '${location}'`, meta: { message: expect.stringContaining('Unexpected identifier'), name: 'SyntaxError', @@ -391,7 +391,7 @@ describe('backend-dynamic-feature-service', () => { main: 'dist/index.cjs.js', }, indexFile: { - retativePath: ['dist', 'index.cjs.js'], + relativePath: ['dist', 'index.cjs.js'], content: 'exports.dynamicPluginInstaller={ kind: "legacy", scaffolder: (env)=>[] }', }, @@ -458,7 +458,7 @@ describe('backend-dynamic-feature-service', () => { mockedFiles[ path.join( url.fileURLToPath(plugin.location), - ...tc.indexFile.retativePath, + ...tc.indexFile.relativePath, ) ] = tc.indexFile.content; } diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index edc3e20f72..95b0c6592c 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -188,7 +188,7 @@ export class DynamicPluginManager implements DynamicPluginProvider { }; } catch (error) { this.logger.error( - `an error occured while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, + `an error occurred while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, error, ); return undefined; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 932ccc74ab..c65fc5cf3e 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -8,14 +8,15 @@ import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common'; import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import { Handler } from 'express'; +import { HumanDuration } from '@backstage/types'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { isChildPath } from '@backstage/cli-common'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; -import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { QueryPermissionRequest } from '@backstage/plugin-permission-common'; import { QueryPermissionResponse } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; @@ -203,9 +204,11 @@ export namespace coreServices { const rootLifecycle: ServiceRef; const rootLogger: ServiceRef; const scheduler: ServiceRef; - const tokenManager: ServiceRef; + const // @deprecated + tokenManager: ServiceRef; const urlReader: ServiceRef; - const identity: ServiceRef; + const // @deprecated + identity: ServiceRef; } // @public @@ -354,6 +357,9 @@ export interface IdentityService extends IdentityApi {} export { isChildPath }; +// @public +export function isDatabaseConflictError(e: unknown): boolean; + // @public (undocumented) export interface LifecycleService { addShutdownHook( @@ -450,6 +456,11 @@ export interface PluginServiceFactoryConfig< service: ServiceRef; } +// @public +export function readSchedulerServiceTaskScheduleDefinitionFromConfig( + config: Config, +): SchedulerServiceTaskScheduleDefinition; + // @public export type ReadTreeOptions = { filter?( @@ -536,8 +547,70 @@ export interface RootServiceFactoryConfig< service: ServiceRef; } -// @public (undocumented) -export interface SchedulerService extends PluginTaskScheduler {} +// @public +export interface SchedulerService { + createScheduledTaskRunner( + schedule: SchedulerServiceTaskScheduleDefinition, + ): SchedulerServiceTaskRunner; + getScheduledTasks(): Promise; + scheduleTask( + task: SchedulerServiceTaskScheduleDefinition & + SchedulerServiceTaskInvocationDefinition, + ): Promise; + triggerTask(id: string): Promise; +} + +// @public +export type SchedulerServiceTaskDescriptor = { + id: string; + scope: 'global' | 'local'; + settings: { + version: number; + } & JsonObject; +}; + +// @public +export type SchedulerServiceTaskFunction = + | ((abortSignal: AbortSignal) => void | Promise) + | (() => void | Promise); + +// @public +export interface SchedulerServiceTaskInvocationDefinition { + fn: SchedulerServiceTaskFunction; + id: string; + signal?: AbortSignal; +} + +// @public +export interface SchedulerServiceTaskRunner { + run(task: SchedulerServiceTaskInvocationDefinition): Promise; +} + +// @public +export interface SchedulerServiceTaskScheduleDefinition { + frequency: + | { + cron: string; + } + | Duration + | HumanDuration; + initialDelay?: Duration | HumanDuration; + scope?: 'global' | 'local'; + timeout: Duration | HumanDuration; +} + +// @public +export interface SchedulerServiceTaskScheduleDefinitionConfig { + frequency: + | { + cron: string; + } + | string + | HumanDuration; + initialDelay?: string | HumanDuration; + scope?: 'global' | 'local'; + timeout: string | HumanDuration; +} // @public export type SearchOptions = { diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 3199c97bda..4569f205f9 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -52,7 +52,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-tasks": "workspace:^", "@backstage/cli-common": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", @@ -60,8 +59,10 @@ "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", + "@types/luxon": "^3.0.0", "express": "^4.17.1", - "knex": "^3.0.0" + "knex": "^3.0.0", + "luxon": "^3.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/packages/backend-plugin-api/src/services/definitions/SchedulerService.test.ts b/packages/backend-plugin-api/src/services/definitions/SchedulerService.test.ts new file mode 100644 index 0000000000..7b20487af4 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/SchedulerService.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { HumanDuration } from '@backstage/types'; +import { Duration } from 'luxon'; +import { readSchedulerServiceTaskScheduleDefinitionFromConfig } from './SchedulerService'; + +describe('readSchedulerServiceTaskScheduleDefinitionFromConfig', () => { + it('all valid values', () => { + const config = new ConfigReader({ + frequency: { + cron: '0 30 * * * *', + }, + timeout: 'PT3M', + initialDelay: { + minutes: 20, + }, + scope: 'global', + }); + + const result = readSchedulerServiceTaskScheduleDefinitionFromConfig(config); + + expect((result.frequency as { cron: string }).cron).toBe('0 30 * * * *'); + expect(result.timeout).toEqual(Duration.fromISO('PT3M')); + expect((result.initialDelay as HumanDuration).minutes).toEqual(20); + expect(result.scope).toBe('global'); + }); + + it('all valid required values', () => { + const config = new ConfigReader({ + frequency: { + cron: '0 30 * * * *', + }, + timeout: 'PT3M', + }); + + const result = readSchedulerServiceTaskScheduleDefinitionFromConfig(config); + + expect((result.frequency as { cron: string }).cron).toBe('0 30 * * * *'); + expect(result.timeout).toEqual(Duration.fromISO('PT3M')); + expect(result.initialDelay).toBeUndefined(); + expect(result.scope).toBeUndefined(); + }); + + it('fail without required frequency', () => { + const config = new ConfigReader({ + timeout: 'PT3M', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow("Missing required config value at 'frequency'"); + }); + + it('fail without required timeout', () => { + const config = new ConfigReader({ + frequency: 'PT30M', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow("Missing required config value at 'timeout'"); + }); + + it('invalid frequency key', () => { + const config = new ConfigReader({ + frequency: { + invalid: 'value', + }, + timeout: 'PT3M', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow( + "Failed to read duration from config at 'frequency', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'", + ); + }); + + it('invalid frequency value', () => { + const config = new ConfigReader({ + frequency: { + minutes: 'value', + }, + timeout: 'PT3M', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow( + "Failed to read duration from config, Error: Unable to convert config value for key 'frequency.minutes' in 'mock-config' to a number", + ); + }); + + it('frequency value with additional invalid prop', () => { + const config = new ConfigReader({ + frequency: { + minutes: 20, + invalid: 'value', + }, + timeout: 'PT3M', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow( + "Failed to read duration from config at 'frequency', Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'", + ); + }); + + it('invalid scope value', () => { + const config = new ConfigReader({ + frequency: { + years: 2, + }, + timeout: 'PT3M', + scope: 'invalid', + }); + + expect(() => + readSchedulerServiceTaskScheduleDefinitionFromConfig(config), + ).toThrow( + 'Only "global" or "local" are allowed for TaskScheduleDefinition.scope, but got: invalid', + ); + }); +}); diff --git a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts index 07c436bd6b..ad0fc1cf3a 100644 --- a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * Copyright 2021 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. @@ -14,7 +14,391 @@ * limitations under the License. */ -import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { HumanDuration, JsonObject } from '@backstage/types'; +import { Duration } from 'luxon'; -/** @public */ -export interface SchedulerService extends PluginTaskScheduler {} +/** + * A function that can be called as a scheduled task. + * + * It may optionally accept an abort signal argument. When the signal triggers, + * processing should abort and return as quickly as possible. + * + * @public + */ +export type SchedulerServiceTaskFunction = + | ((abortSignal: AbortSignal) => void | Promise) + | (() => void | Promise); + +/** + * A semi-opaque type to describe an actively scheduled task. + * + * @public + */ +export type SchedulerServiceTaskDescriptor = { + /** + * The unique identifier of the task. + */ + id: string; + /** + * The scope of the task. + */ + scope: 'global' | 'local'; + /** + * The settings that control the task flow. This is a semi-opaque structure + * that is mainly there for debugging purposes. Do not make any assumptions + * about the contents of this field. + */ + settings: { version: number } & JsonObject; +}; + +/** + * Options that control the scheduling of a task. + * + * @public + */ +export interface SchedulerServiceTaskScheduleDefinition { + /** + * How often you want the task to run. The system does its best to avoid + * overlapping invocations. + * + * @remarks + * + * This is the best effort value; under some circumstances there can be + * deviations. For example, if the task runtime is longer than the frequency + * and the timeout has not been given or not been exceeded yet, the next + * invocation of this task will be delayed until after the previous one + * finishes. + * + * This is a required field. + */ + frequency: + | { + /** + * A crontab style string. + * + * @remarks + * + * Overview: + * + * ``` + * ┌────────────── second (optional) + * │ ┌──────────── minute + * │ │ ┌────────── hour + * │ │ │ ┌──────── day of month + * │ │ │ │ ┌────── month + * │ │ │ │ │ ┌──── day of week + * │ │ │ │ │ │ + * │ │ │ │ │ │ + * * * * * * * + * ``` + */ + cron: string; + } + | Duration + | HumanDuration; + + /** + * The maximum amount of time that a single task invocation can take, before + * it's considered timed out and gets "released" such that a new invocation + * is permitted to take place (possibly, then, on a different worker). + */ + timeout: Duration | HumanDuration; + + /** + * The amount of time that should pass before the first invocation happens. + * + * @remarks + * + * This can be useful in cold start scenarios to stagger or delay some heavy + * compute jobs. If no value is given for this field then the first invocation + * will happen as soon as possible according to the cadence. + * + * NOTE: This is a per-worker delay. If you have a cluster of workers all + * collaborating on a task that has its `scope` field set to `'global'`, then + * you may still see the task being processed by other long-lived workers, + * while any given single worker is in its initial sleep delay time e.g. after + * a deployment. Therefore, this parameter is not useful for "globally" pausing + * work; its main intended use is for individual machines to get a chance to + * reach some equilibrium at startup before triggering heavy batch workloads. + */ + initialDelay?: Duration | HumanDuration; + + /** + * Sets the scope of concurrency control / locking to apply for invocations of + * this task. + * + * @remarks + * + * When the scope is set to the default value `'global'`, the scheduler will + * attempt to ensure that only one worker machine runs the task at a time, + * according to the given cadence. This means that as the number of worker + * hosts increases, the invocation frequency of this task will not go up. + * Instead, the load is spread randomly across hosts. This setting is useful + * for tasks that access shared resources, for example catalog ingestion tasks + * where you do not want many machines to repeatedly import the same data and + * trample over each other. + * + * When the scope is set to `'local'`, there is no concurrency control across + * hosts. Each host runs the task according to the given cadence similarly to + * `setInterval`, but the runtime ensures that there are no overlapping runs. + * + * @defaultValue 'global' + */ + scope?: 'global' | 'local'; +} + +/** + * Config options for {@link SchedulerServiceTaskScheduleDefinition} + * that control the scheduling of a task. + * + * @public + */ +export interface SchedulerServiceTaskScheduleDefinitionConfig { + /** + * How often you want the task to run. The system does its best to avoid + * overlapping invocations. + * + * @remarks + * + * This is the best effort value; under some circumstances there can be + * deviations. For example, if the task runtime is longer than the frequency + * and the timeout has not been given or not been exceeded yet, the next + * invocation of this task will be delayed until after the previous one + * finishes. + * + * This is a required field. + */ + frequency: + | { + /** + * A crontab style string. + * + * @remarks + * + * Overview: + * + * ``` + * ┌────────────── second (optional) + * │ ┌──────────── minute + * │ │ ┌────────── hour + * │ │ │ ┌──────── day of month + * │ │ │ │ ┌────── month + * │ │ │ │ │ ┌──── day of week + * │ │ │ │ │ │ + * │ │ │ │ │ │ + * * * * * * * + * ``` + */ + cron: string; + } + | string + | HumanDuration; + + /** + * The maximum amount of time that a single task invocation can take, before + * it's considered timed out and gets "released" such that a new invocation + * is permitted to take place (possibly, then, on a different worker). + */ + timeout: string | HumanDuration; + + /** + * The amount of time that should pass before the first invocation happens. + * + * @remarks + * + * This can be useful in cold start scenarios to stagger or delay some heavy + * compute jobs. If no value is given for this field then the first invocation + * will happen as soon as possible according to the cadence. + * + * NOTE: This is a per-worker delay. If you have a cluster of workers all + * collaborating on a task that has its `scope` field set to `'global'`, then + * you may still see the task being processed by other long-lived workers, + * while any given single worker is in its initial sleep delay time e.g. after + * a deployment. Therefore, this parameter is not useful for "globally" pausing + * work; its main intended use is for individual machines to get a chance to + * reach some equilibrium at startup before triggering heavy batch workloads. + */ + initialDelay?: string | HumanDuration; + + /** + * Sets the scope of concurrency control / locking to apply for invocations of + * this task. + * + * @remarks + * + * When the scope is set to the default value `'global'`, the scheduler will + * attempt to ensure that only one worker machine runs the task at a time, + * according to the given cadence. This means that as the number of worker + * hosts increases, the invocation frequency of this task will not go up. + * Instead, the load is spread randomly across hosts. This setting is useful + * for tasks that access shared resources, for example catalog ingestion tasks + * where you do not want many machines to repeatedly import the same data and + * trample over each other. + * + * When the scope is set to `'local'`, there is no concurrency control across + * hosts. Each host runs the task according to the given cadence similarly to + * `setInterval`, but the runtime ensures that there are no overlapping runs. + * + * @defaultValue 'global' + */ + scope?: 'global' | 'local'; +} + +/** + * Options that apply to the invocation of a given task. + * + * @public + */ +export interface SchedulerServiceTaskInvocationDefinition { + /** + * A unique ID (within the scope of the plugin) for the task. + */ + id: string; + + /** + * The actual task function to be invoked regularly. + */ + fn: SchedulerServiceTaskFunction; + + /** + * An abort signal that, when triggered, will stop the recurring execution of + * the task. + */ + signal?: AbortSignal; +} + +/** + * A previously prepared task schedule, ready to be invoked. + * + * @public + */ +export interface SchedulerServiceTaskRunner { + /** + * Takes the schedule and executes an actual task using it. + * + * @param task - The actual runtime properties of the task + */ + run(task: SchedulerServiceTaskInvocationDefinition): Promise; +} + +/** + * Deals with the scheduling of distributed tasks, for a given plugin. + * + * @public + */ +export interface SchedulerService { + /** + * Manually triggers a task by ID. + * + * If the task doesn't exist, a NotFoundError is thrown. If the task is + * currently running, a ConflictError is thrown. + * + * @param id - The task ID + */ + triggerTask(id: string): Promise; + + /** + * Schedules a task function for recurring runs. + * + * @remarks + * + * The `scope` task field controls whether to use coordinated exclusive + * invocation across workers, or to just coordinate within the current worker. + * + * This convenience method performs both the scheduling and invocation in one + * go. + * + * @param task - The task definition + */ + scheduleTask( + task: SchedulerServiceTaskScheduleDefinition & + SchedulerServiceTaskInvocationDefinition, + ): Promise; + + /** + * Creates a scheduled but dormant recurring task, ready to be launched at a + * later time. + * + * @remarks + * + * This method is useful for pre-creating a schedule in outer code to be + * passed into an inner implementation, such that the outer code controls + * scheduling while inner code controls implementation. + * + * @param schedule - The task schedule + */ + createScheduledTaskRunner( + schedule: SchedulerServiceTaskScheduleDefinition, + ): SchedulerServiceTaskRunner; + + /** + * Returns all scheduled tasks registered to this scheduler. + * + * @remarks + * + * This method is useful for triggering tasks manually using the triggerTask + * functionality. Note that the returned tasks contain only tasks that have + * been initialized in this instance of the scheduler. + * + * @returns Scheduled tasks + */ + getScheduledTasks(): Promise; +} + +function readDuration(config: Config, key: string): Duration | HumanDuration { + if (typeof config.get(key) === 'string') { + const value = config.getString(key); + const duration = Duration.fromISO(value); + if (!duration.isValid) { + throw new Error(`Invalid duration: ${value}`); + } + return duration; + } + + return readDurationFromConfig(config, { key }); +} + +function readCronOrDuration( + config: Config, + key: string, +): { cron: string } | Duration | HumanDuration { + const value = config.get(key); + if (typeof value === 'object' && (value as { cron?: string }).cron) { + return value as { cron: string }; + } + + return readDuration(config, key); +} + +/** + * Reads a {@link SchedulerServiceTaskScheduleDefinition} from config. Expects + * the config not to be the root config, but the config for the definition. + * + * @param config - config for a TaskScheduleDefinition. + * @public + */ +export function readSchedulerServiceTaskScheduleDefinitionFromConfig( + config: Config, +): SchedulerServiceTaskScheduleDefinition { + const frequency = readCronOrDuration(config, 'frequency'); + const timeout = readDuration(config, 'timeout'); + + const initialDelay = config.has('initialDelay') + ? readDuration(config, 'initialDelay') + : undefined; + + const scope = config.getOptionalString('scope'); + if (scope && !['global', 'local'].includes(scope)) { + throw new Error( + `Only "global" or "local" are allowed for TaskScheduleDefinition.scope, but got: ${scope}`, + ); + } + + return { + frequency, + timeout, + initialDelay, + scope: scope as 'global' | 'local' | undefined, + }; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index c760afc7b4..4e02611f80 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -172,6 +172,7 @@ export namespace coreServices { * The service reference for the plugin scoped {@link TokenManagerService}. * * @public + * @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead */ export const tokenManager = createServiceRef< import('./TokenManagerService').TokenManagerService @@ -190,6 +191,7 @@ export namespace coreServices { * The service reference for the plugin scoped {@link IdentityService}. * * @public + * @deprecated Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead */ export const identity = createServiceRef< import('./IdentityService').IdentityService diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 8a5176379f..5739add90c 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -52,7 +52,16 @@ export type { PluginMetadataService } from './PluginMetadataService'; export type { RootHttpRouterService } from './RootHttpRouterService'; export type { RootLifecycleService } from './RootLifecycleService'; export type { RootLoggerService } from './RootLoggerService'; -export type { SchedulerService } from './SchedulerService'; +export { readSchedulerServiceTaskScheduleDefinitionFromConfig } from './SchedulerService'; +export type { + SchedulerService, + SchedulerServiceTaskDescriptor, + SchedulerServiceTaskFunction, + SchedulerServiceTaskInvocationDefinition, + SchedulerServiceTaskRunner, + SchedulerServiceTaskScheduleDefinition, + SchedulerServiceTaskScheduleDefinitionConfig, +} from './SchedulerService'; export type { TokenManagerService } from './TokenManagerService'; export type { ReadTreeOptions, diff --git a/packages/backend-plugin-api/src/services/index.ts b/packages/backend-plugin-api/src/services/index.ts index dd24127b88..01d3da444b 100644 --- a/packages/backend-plugin-api/src/services/index.ts +++ b/packages/backend-plugin-api/src/services/index.ts @@ -16,3 +16,4 @@ export * from './definitions'; export * from './system'; +export * from './utilities'; diff --git a/packages/backend-common/src/database/util.ts b/packages/backend-plugin-api/src/services/utilities/database.ts similarity index 100% rename from packages/backend-common/src/database/util.ts rename to packages/backend-plugin-api/src/services/utilities/database.ts diff --git a/packages/backend-plugin-api/src/services/utilities/index.ts b/packages/backend-plugin-api/src/services/utilities/index.ts new file mode 100644 index 0000000000..0eec4f3e84 --- /dev/null +++ b/packages/backend-plugin-api/src/services/utilities/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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 { isDatabaseConflictError } from './database'; diff --git a/packages/backend-tasks/api-report.md b/packages/backend-tasks/api-report.md index 4bfda20c36..6e8d6eeb2f 100644 --- a/packages/backend-tasks/api-report.md +++ b/packages/backend-tasks/api-report.md @@ -14,7 +14,7 @@ import { PluginDatabaseManager } from '@backstage/backend-common'; // @public @deprecated export type HumanDuration = HumanDuration_2; -// @public +// @public @deprecated export interface PluginTaskScheduler { createScheduledTaskRunner(schedule: TaskScheduleDefinition): TaskRunner; getScheduledTasks(): Promise; @@ -24,12 +24,12 @@ export interface PluginTaskScheduler { triggerTask(id: string): Promise; } -// @public +// @public @deprecated export function readTaskScheduleDefinitionFromConfig( config: Config, ): TaskScheduleDefinition; -// @public +// @public @deprecated export type TaskDescriptor = { id: string; scope: 'global' | 'local'; @@ -38,24 +38,24 @@ export type TaskDescriptor = { } & JsonObject; }; -// @public +// @public @deprecated export type TaskFunction = | ((abortSignal: AbortSignal) => void | Promise) | (() => void | Promise); -// @public +// @public @deprecated export interface TaskInvocationDefinition { fn: TaskFunction; id: string; signal?: AbortSignal; } -// @public +// @public @deprecated export interface TaskRunner { run(task: TaskInvocationDefinition): Promise; } -// @public +// @public @deprecated export interface TaskScheduleDefinition { frequency: | { @@ -68,7 +68,7 @@ export interface TaskScheduleDefinition { timeout: Duration | HumanDuration_2; } -// @public +// @public @deprecated export interface TaskScheduleDefinitionConfig { frequency: | { @@ -81,7 +81,7 @@ export interface TaskScheduleDefinitionConfig { timeout: string | HumanDuration_2; } -// @public +// @public @deprecated export class TaskScheduler { constructor( databaseManager: LegacyRootDatabaseService, diff --git a/packages/backend-tasks/src/deprecated.ts b/packages/backend-tasks/src/deprecated.ts index 4d86cb1700..629e5f6d4d 100644 --- a/packages/backend-tasks/src/deprecated.ts +++ b/packages/backend-tasks/src/deprecated.ts @@ -23,3 +23,5 @@ import { HumanDuration as TypesHumanDuration } from '@backstage/types'; * @deprecated Import from `@backstage/types` instead */ export type HumanDuration = TypesHumanDuration; + +export * from './tasks'; diff --git a/packages/backend-tasks/src/index.ts b/packages/backend-tasks/src/index.ts index d35917056c..0aae81b143 100644 --- a/packages/backend-tasks/src/index.ts +++ b/packages/backend-tasks/src/index.ts @@ -20,5 +20,4 @@ * @packageDocumentation */ -export * from './tasks'; export * from './deprecated'; diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.ts b/packages/backend-tasks/src/tasks/TaskScheduler.ts index cc589ea949..6608ba8e84 100644 --- a/packages/backend-tasks/src/tasks/TaskScheduler.ts +++ b/packages/backend-tasks/src/tasks/TaskScheduler.ts @@ -33,6 +33,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; * Deals with the scheduling of distributed tasks. * * @public + * @deprecated Please migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead. */ export class TaskScheduler { /** diff --git a/packages/backend-tasks/src/tasks/readTaskScheduleDefinitionFromConfig.ts b/packages/backend-tasks/src/tasks/readTaskScheduleDefinitionFromConfig.ts index 8053936263..5a173f246b 100644 --- a/packages/backend-tasks/src/tasks/readTaskScheduleDefinitionFromConfig.ts +++ b/packages/backend-tasks/src/tasks/readTaskScheduleDefinitionFromConfig.ts @@ -51,6 +51,7 @@ function readCronOrDuration( * * @param config - config for a TaskScheduleDefinition. * @public + * @deprecated Please import `readSchedulerServiceTaskScheduleDefinitionFromConfig` from `@backstage/backend-plugin-api` instead */ export function readTaskScheduleDefinitionFromConfig( config: Config, diff --git a/packages/backend-tasks/src/tasks/types.ts b/packages/backend-tasks/src/tasks/types.ts index 14339dda0e..955909e040 100644 --- a/packages/backend-tasks/src/tasks/types.ts +++ b/packages/backend-tasks/src/tasks/types.ts @@ -26,6 +26,7 @@ import { z } from 'zod'; * processing should abort and return as quickly as possible. * * @public + * @deprecated Please import `SchedulerServiceTaskFunction` from `@backstage/backend-plugin-api` instead */ export type TaskFunction = | ((abortSignal: AbortSignal) => void | Promise) @@ -35,6 +36,7 @@ export type TaskFunction = * A semi-opaque type to describe an actively scheduled task. * * @public + * @deprecated Please import `SchedulerServiceTaskDescriptor` from `@backstage/backend-plugin-api` instead */ export type TaskDescriptor = { /** @@ -57,6 +59,7 @@ export type TaskDescriptor = { * Options that control the scheduling of a task. * * @public + * @deprecated Please import `SchedulerServiceTaskScheduleDefinition` from `@backstage/backend-plugin-api` instead */ export interface TaskScheduleDefinition { /** @@ -154,6 +157,7 @@ export interface TaskScheduleDefinition { * that control the scheduling of a task. * * @public + * @deprecated Please import `SchedulerServiceTaskDefinitionConfig` from `@backstage/backend-plugin-api` instead */ export interface TaskScheduleDefinitionConfig { /** @@ -250,6 +254,7 @@ export interface TaskScheduleDefinitionConfig { * Options that apply to the invocation of a given task. * * @public + * @deprecated Please import `SchedulerServiceTaskInvocationDefinition` from `@backstage/backend-plugin-api` instead */ export interface TaskInvocationDefinition { /** @@ -273,6 +278,7 @@ export interface TaskInvocationDefinition { * A previously prepared task schedule, ready to be invoked. * * @public + * @deprecated Please import `SchedulerServiceTaskRunner` from `@backstage/backend-plugin-api` instead */ export interface TaskRunner { /** @@ -287,6 +293,7 @@ export interface TaskRunner { * Deals with the scheduling of distributed tasks, for a given plugin. * * @public + * @deprecated Please use `SchedulerService` from `@backstage/backend-plugin-api` instead (most likely via `coreServices.scheduler`) */ export interface PluginTaskScheduler { /** diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index f32236e977..9fe041a839 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -65,11 +65,6 @@ describe('FieldFormatEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/kind/); }); - it('handles missing metadata gracefully', async () => { - delete data.medatata; - await expect(policy.enforce(data)).resolves.toBe(data); - }); - it('handles missing spec gracefully', async () => { delete data.spec; await expect(policy.enforce(data)).resolves.toBe(data); diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 3bd9a98f60..a786d71949 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -35,7 +35,7 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", - "@yarnpkg/parsers": "^3.0.0-rc.4", + "@yarnpkg/parsers": "^3.0.0", "fs-extra": "^11.2.0", "semver": "^7.5.3", "zod": "^3.22.4" diff --git a/packages/cli/package.json b/packages/cli/package.json index f3093445ab..67492919d0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -79,7 +79,7 @@ "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.7.2", "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "^3.0.0-rc.4", + "@yarnpkg/parsers": "^3.0.0", "bfj": "^8.0.0", "buffer": "^6.0.3", "chalk": "^4.0.0", diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index 50d0a77cfd..6af115a4da 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -98,6 +98,24 @@ export class Lockfile { return Lockfile.parse(lockfileContents); } + static #getRangesFromDataKey(key: string): string[] { + const [, name, ranges] = key.match(ENTRY_PATTERN) ?? []; + if (!name) { + throw new Error(`Failed to parse yarn.lock entry '${key}'`); + } + + return ranges.split(/\s*,\s*/).map(rangePart => { + let range = rangePart; + if (range.startsWith(`${name}@`)) { + range = range.slice(`${name}@`.length); + } + if (range.startsWith('npm:')) { + range = range.slice('npm:'.length); + } + return range; + }); + } + static parse(content: string) { const legacy = LEGACY_REGEX.test(content); @@ -113,7 +131,7 @@ export class Lockfile { for (const [key, value] of Object.entries(data)) { if (SPECIAL_OBJECT_KEYS.includes(key)) continue; - const [, name, ranges] = ENTRY_PATTERN.exec(key) ?? []; + const [, name] = ENTRY_PATTERN.exec(key) ?? []; if (!name) { throw new Error(`Failed to parse yarn.lock entry '${key}'`); } @@ -123,13 +141,8 @@ export class Lockfile { queries = []; packages.set(name, queries); } - for (let range of ranges.split(/\s*,\s*/)) { - if (range.startsWith(`${name}@`)) { - range = range.slice(`${name}@`.length); - } - if (range.startsWith('npm:')) { - range = range.slice('npm:'.length); - } + const ranges = Lockfile.#getRangesFromDataKey(key); + for (const range of ranges) { queries.push({ range, version: value.version, dataKey: key }); } } @@ -276,9 +289,34 @@ export class Lockfile { } remove(name: string, range: string): boolean { - const query = `${name}@${range}`; + const simpleQuery = `${name}@${range}`; + const query = this.getEntryOf(name, range); + const existed = Boolean(this.data[query]); - delete this.data[query]; + + if (simpleQuery === query) { + // Single-versioned entry, just delete + delete this.data[query]; + } else { + // Remove this version from the entry key. This modifies the key, so the + // package queries' needs to be updated too. + const newRanges = Lockfile.#getRangesFromDataKey(query).filter( + q => q !== simpleQuery, + ); + const newQuery = newRanges.join(', '); + + // Replace the entry with a new one without this particular range + const entry = this.data[query]; + delete this.data[query]; + this.data[newQuery] = entry; + + // Fix all package queries pointing to the old query + this.packages.get(name)?.forEach(q => { + if (q.dataKey === query) { + q.dataKey = newQuery; + } + }); + } const newEntries = this.packages.get(name)?.filter(e => e.range !== range); if (newEntries) { @@ -288,19 +326,35 @@ export class Lockfile { return existed; } + getEntryOf(name: string, range: string) { + const query = this.packages.get(name)?.find(q => q.range === range); + if (!query) { + throw new Error(`No entry data for ${name}@${range}`); + } + + return query.dataKey; + } + /** Modifies the lockfile by bumping packages to the suggested versions */ replaceVersions(results: AnalyzeResultNewVersion[]) { + // When replacing versions, we might replace the same version multiple times, + // as a query may contain multiple versions. This keeps the original version, + // to ensure we don't make mistakes. + const oldVersions = Object.fromEntries( + Object.entries(this.data).map(([key, val]) => [key, val.version]), + ); + for (const { name, range, oldVersion, newVersion } of results) { - const query = `${name}@${range}`; + const query = this.getEntryOf(name, range); // Update the backing data const entryData = this.data[query]; if (!entryData) { throw new Error(`No entry data for ${query}`); } - if (entryData.version !== oldVersion) { + if (oldVersions[query] !== oldVersion) { throw new Error( - `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + `Expected existing version data for ${query} to be ${oldVersion}, was ${oldVersions[query]}`, ); } diff --git a/packages/theme/src/unified/UnifiedThemeProvider.test.tsx b/packages/theme/src/unified/UnifiedThemeProvider.test.tsx index da4af5abab..98fc73505d 100644 --- a/packages/theme/src/unified/UnifiedThemeProvider.test.tsx +++ b/packages/theme/src/unified/UnifiedThemeProvider.test.tsx @@ -22,7 +22,6 @@ import { useTheme as useV5Theme } from '@mui/material/styles'; import { makeStyles as makeV5Styles } from '@mui/styles'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import './MuiClassNameSetup'; import { UnifiedThemeProvider } from './UnifiedThemeProvider'; import { themes } from './themes'; diff --git a/packages/theme/src/unified/UnifiedThemeProvider.tsx b/packages/theme/src/unified/UnifiedThemeProvider.tsx index 35abbe57d8..465a4dda24 100644 --- a/packages/theme/src/unified/UnifiedThemeProvider.tsx +++ b/packages/theme/src/unified/UnifiedThemeProvider.tsx @@ -15,7 +15,6 @@ */ import React, { ReactNode } from 'react'; -import './MuiClassNameSetup'; import { CssBaseline } from '@material-ui/core'; import { ThemeProvider, @@ -29,6 +28,7 @@ import { Theme as Mui5Theme, } from '@mui/material/styles'; import { UnifiedTheme } from './types'; +import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; /** * Props for {@link UnifiedThemeProvider}. @@ -41,10 +41,19 @@ export interface UnifiedThemeProviderProps { noCssBaseline?: boolean; } +/** + * This API is introduced in @mui/material (v5.0.5) as a replacement of deprecated createGenerateClassName & only affects v5 Material UI components from `@mui/*`. + * + * This call needs to be in the same module as the `UnifiedThemeProvider` to ensure that it doesn't get removed by tree shaking + */ +ClassNameGenerator.configure(componentName => { + return `v5-${componentName}`; +}); + // Background at https://mui.com/x/migration/migration-data-grid-v4/#using-mui-core-v4-with-v5 // Rather than disabling globals and custom seed, we instead only set a production prefix that -// won't collide with Material UI 5 styles. We've already got a separate class name generator for v5 set -// up in MuiClassNameSetup.ts, so only the production JSS needs deduplication. +// won't collide with Material UI 5 styles. We've already got the separate class name generator +// for v5 set up in just above, so only the production JSS needs deduplication. const generateV4ClassName = createGenerateClassName({ productionPrefix: 'jss4-', }); diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md index 7523198298..2fc852777b 100644 --- a/plugins/api-docs/api-report.md +++ b/plugins/api-docs/api-report.md @@ -9,6 +9,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { CatalogTableRow } from '@backstage/plugin-catalog'; +import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; @@ -105,6 +106,7 @@ export type DefaultApiExplorerPageProps = { initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; actions?: TableProps['actions']; + ownerPickerMode?: EntityOwnerPickerProps['mode']; }; // @public (undocumented) diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx index dc83c2528d..6b5917c52d 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx @@ -35,6 +35,7 @@ import { UserListFilterKind, UserListPicker, CatalogFilterLayout, + EntityOwnerPickerProps, } from '@backstage/plugin-catalog-react'; import React from 'react'; import { registerComponentRouteRef } from '../../routes'; @@ -60,6 +61,7 @@ export type DefaultApiExplorerPageProps = { initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; actions?: TableProps['actions']; + ownerPickerMode?: EntityOwnerPickerProps['mode']; }; /** @@ -67,7 +69,12 @@ export type DefaultApiExplorerPageProps = { * @public */ export const DefaultApiExplorerPage = (props: DefaultApiExplorerPageProps) => { - const { initiallySelectedFilter = 'all', columns, actions } = props; + const { + initiallySelectedFilter = 'all', + columns, + actions, + ownerPickerMode, + } = props; const configApi = useApi(configApiRef); const generatedSubtitle = `${ @@ -101,7 +108,7 @@ export const DefaultApiExplorerPage = (props: DefaultApiExplorerPageProps) => {