Merge remote-tracking branch 'origin/master' into bui-toast

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor

# Conflicts:
#	packages/frontend-defaults/src/createPublicSignInApp.test.tsx
#	yarn.lock
This commit is contained in:
Patrik Oldsberg
2026-03-17 12:05:59 +01:00
2867 changed files with 84530 additions and 39061 deletions
+23
View File
@@ -1,5 +1,28 @@
# @backstage/app-defaults
## 1.7.6-next.0
### Patch Changes
- Updated dependencies
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-permission-react@0.4.41-next.0
## 1.7.5
### Patch Changes
- a7e0d50: Updated `react-router-dom` peer dependency to `^6.30.2` and explicitly disabled v7 future flags to suppress deprecation warnings.
- Updated dependencies
- @backstage/core-components@0.18.7
- @backstage/core-app-api@1.19.5
- @backstage/theme@0.7.2
- @backstage/core-plugin-api@1.12.3
- @backstage/plugin-permission-react@0.4.40
## 1.7.5-next.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/app-defaults",
"version": "1.7.5-next.2",
"version": "1.7.6-next.0",
"description": "Provides the default wiring of a Backstage App",
"backstage": {
"role": "web-library"
+24
View File
@@ -1,5 +1,29 @@
# app-example-plugin
## 0.0.33-next.1
### Patch Changes
- Updated dependencies
- @backstage/frontend-plugin-api@0.15.0-next.1
- @backstage/core-components@0.18.8-next.1
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/core-components@0.18.8-next.0
## 0.0.32
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.18.7
- @backstage/frontend-plugin-api@0.14.0
## 0.0.32-next.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-example-plugin",
"version": "0.0.32-next.0",
"version": "0.0.33-next.1",
"description": "Backstage internal example plugin",
"backstage": {
"role": "frontend-plugin",
+56 -4
View File
@@ -4,7 +4,10 @@
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSX as JSX_3 } from 'react/jsx-runtime';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -21,26 +24,75 @@ const examplePlugin: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
+164
View File
@@ -1,5 +1,169 @@
# example-app-legacy
## 0.2.119-next.2
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.2
- @backstage/cli@0.36.0-next.2
- @backstage/core-app-api@1.19.6-next.1
- @backstage/plugin-techdocs@1.17.1-next.2
- @backstage/core-plugin-api@1.12.4-next.1
- @backstage/frontend-app-api@0.16.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.2
- @backstage/core-components@0.18.8-next.1
- @backstage/plugin-org@0.7.0-next.2
- @backstage/plugin-catalog-graph@0.6.0-next.2
- @backstage/plugin-mui-to-bui@0.2.5-next.2
- @backstage/plugin-scaffolder-react@1.20.0-next.2
- @backstage/plugin-api-docs@0.13.5-next.2
- @backstage/plugin-catalog@2.0.0-next.2
- @backstage/plugin-catalog-import@0.13.11-next.2
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.1
- @backstage/plugin-devtools@0.1.37-next.2
- @backstage/plugin-home@0.9.3-next.2
- @backstage/plugin-home-react@0.1.36-next.1
- @backstage/plugin-kubernetes@0.12.17-next.2
- @backstage/plugin-notifications@0.5.15-next.1
- @backstage/plugin-scaffolder@1.35.5-next.2
- @backstage/plugin-search@1.6.2-next.2
- @backstage/plugin-search-react@1.10.5-next.1
- @backstage/plugin-signals@0.0.29-next.1
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.2
- @backstage/plugin-techdocs-react@1.3.9-next.1
- @backstage/plugin-user-settings@0.9.1-next.2
## 0.2.119-next.1
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.1
- @backstage/cli@0.36.0-next.1
- @backstage/plugin-devtools@0.1.37-next.1
- @backstage/plugin-scaffolder@1.35.5-next.1
- @backstage/plugin-api-docs@0.13.5-next.1
- @backstage/plugin-techdocs@1.17.1-next.1
- @backstage/plugin-catalog@1.34.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.1
- @backstage/plugin-scaffolder-react@1.19.8-next.1
- @backstage/plugin-mui-to-bui@0.2.5-next.1
- @backstage/plugin-catalog-graph@0.5.8-next.1
- @backstage/plugin-catalog-import@0.13.11-next.1
- @backstage/plugin-home@0.9.3-next.1
- @backstage/plugin-org@0.6.50-next.1
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/integration-react@1.2.16-next.1
- @backstage/theme@0.7.2
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.1
- @backstage/plugin-kubernetes-cluster@0.0.35-next.1
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-search@1.6.2-next.1
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.1
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.1
## 0.2.119-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-mui-to-bui@0.2.5-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.2.118
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.0
- @backstage/frontend-app-api@0.15.0
- @backstage/plugin-catalog-react@2.0.0
- @backstage/plugin-api-docs@0.13.4
- @backstage/plugin-catalog-graph@0.5.7
- @backstage/plugin-org@0.6.49
- @backstage/cli@0.35.4
- @backstage/core-components@0.18.7
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-scaffolder@1.35.3
- @backstage/plugin-notifications@0.5.14
- @backstage/plugin-catalog@1.33.0
- @backstage/plugin-mui-to-bui@0.2.4
- @backstage/core-app-api@1.19.5
- @backstage/plugin-techdocs@1.17.0
- @backstage/plugin-kubernetes@0.12.16
- @backstage/theme@0.7.2
- @backstage/plugin-catalog-unprocessed-entities@0.2.26
- @backstage/plugin-devtools@0.1.36
- @backstage/plugin-home@0.9.2
- @backstage/plugin-search@1.6.0
- @backstage/plugin-user-settings@0.9.0
- @backstage/plugin-scaffolder-react@1.19.7
- @backstage/core-plugin-api@1.12.3
- @backstage/plugin-home-react@0.1.35
- @backstage/plugin-techdocs-react@1.3.8
- @backstage/plugin-techdocs-module-addons-contrib@1.1.33
- @backstage/integration-react@1.2.15
- @backstage/plugin-kubernetes-cluster@0.0.34
- @backstage/plugin-permission-react@0.4.40
- @backstage/plugin-catalog-import@0.13.10
- @backstage/app-defaults@1.7.5
- @backstage/plugin-search-react@1.10.3
- @backstage/plugin-auth-react@0.1.24
- @backstage/plugin-signals@0.0.28
- @backstage/plugin-catalog-common@1.1.8
## 0.2.118-next.2
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-app-legacy",
"version": "0.2.118-next.2",
"version": "0.2.119-next.2",
"backstage": {
"role": "frontend"
},
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/jquery": "^3.3.34",
"@types/jquery": "^4.0.0",
"@types/react": "*",
"@types/react-dom": "*",
"@types/zen-observable": "^0.8.0",
@@ -166,11 +166,11 @@ const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6} xs={12}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
@@ -299,7 +299,7 @@ const apiPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item xs={12}>
<Grid container>
@@ -330,13 +330,10 @@ const userPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -349,13 +346,10 @@ const groupPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
<Grid item xs={12} md={6}>
<EntityMembersListCard />
@@ -374,10 +368,10 @@ const systemPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasComponentsCard variant="gridItem" />
@@ -392,7 +386,6 @@ const systemPage = (
</EntityLayout.Route>
<EntityLayout.Route path="/diagram" title="Diagram">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
height={700}
@@ -418,10 +411,10 @@ const domainPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
@@ -437,10 +430,10 @@ const resourcePage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
+188
View File
@@ -1,5 +1,193 @@
# example-app
## 0.0.33-next.2
### Patch Changes
- Updated dependencies
- @backstage/core-compat-api@0.5.9-next.2
- @backstage/ui@0.13.0-next.2
- @backstage/cli@0.36.0-next.2
- @backstage/core-app-api@1.19.6-next.1
- @backstage/plugin-techdocs@1.17.1-next.2
- @backstage/frontend-plugin-api@0.15.0-next.1
- @backstage/core-plugin-api@1.12.4-next.1
- @backstage/frontend-app-api@0.16.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.2
- @backstage/core-components@0.18.8-next.1
- @backstage/plugin-org@0.7.0-next.2
- @backstage/plugin-catalog-graph@0.6.0-next.2
- @backstage/plugin-app-react@0.2.1-next.1
- @backstage/plugin-app@0.4.1-next.2
- @backstage/frontend-defaults@0.5.0-next.1
- @backstage/plugin-scaffolder-react@1.20.0-next.2
- @backstage/plugin-api-docs@0.13.5-next.2
- @backstage/plugin-catalog@2.0.0-next.2
- @backstage/plugin-app-visualizer@0.2.1-next.2
- @backstage/plugin-auth@0.1.6-next.1
- @backstage/plugin-catalog-import@0.13.11-next.2
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.1
- @backstage/plugin-devtools@0.1.37-next.2
- @backstage/plugin-home@0.9.3-next.2
- @backstage/plugin-home-react@0.1.36-next.1
- @backstage/plugin-kubernetes@0.12.17-next.2
- @backstage/plugin-notifications@0.5.15-next.1
- @backstage/plugin-scaffolder@1.35.5-next.2
- @backstage/plugin-search@1.6.2-next.2
- @backstage/plugin-search-react@1.10.5-next.1
- @backstage/plugin-signals@0.0.29-next.1
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.2
- @backstage/plugin-techdocs-react@1.3.9-next.1
- @backstage/plugin-user-settings@0.9.1-next.2
## 0.0.33-next.1
### Patch Changes
- Updated dependencies
- @backstage/ui@0.13.0-next.1
- @backstage/cli@0.36.0-next.1
- @backstage/plugin-devtools@0.1.37-next.1
- @backstage/plugin-scaffolder@1.35.5-next.1
- @backstage/plugin-api-docs@0.13.5-next.1
- @backstage/plugin-techdocs@1.17.1-next.1
- @backstage/plugin-catalog@1.34.0-next.1
- @backstage/plugin-catalog-react@2.1.0-next.1
- @backstage/plugin-scaffolder-react@1.19.8-next.1
- @backstage/plugin-app@0.4.1-next.1
- @backstage/plugin-app-visualizer@0.2.1-next.1
- @backstage/plugin-catalog-graph@0.5.8-next.1
- @backstage/plugin-catalog-import@0.13.11-next.1
- @backstage/plugin-home@0.9.3-next.1
- @backstage/plugin-org@0.6.50-next.1
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-compat-api@0.5.9-next.1
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/frontend-defaults@0.4.1-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/integration-react@1.2.16-next.1
- @backstage/theme@0.7.2
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-auth@0.1.6-next.0
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.1
- @backstage/plugin-kubernetes-cluster@0.0.35-next.1
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-search@1.6.2-next.1
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.1
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.1
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-app@0.4.1-next.0
- @backstage/plugin-app-visualizer@0.2.1-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-compat-api@0.5.9-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/frontend-defaults@0.4.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-auth@0.1.6-next.0
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.0.32
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.0
- @backstage/frontend-defaults@0.4.0
- @backstage/frontend-app-api@0.15.0
- @backstage/plugin-app-visualizer@0.2.0
- @backstage/plugin-catalog-react@2.0.0
- @backstage/plugin-api-docs@0.13.4
- @backstage/plugin-catalog-graph@0.5.7
- @backstage/plugin-org@0.6.49
- @backstage/cli@0.35.4
- @backstage/core-components@0.18.7
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-scaffolder@1.35.3
- @backstage/plugin-notifications@0.5.14
- @backstage/plugin-catalog@1.33.0
- @backstage/core-app-api@1.19.5
- @backstage/plugin-techdocs@1.17.0
- @backstage/plugin-kubernetes@0.12.16
- @backstage/core-compat-api@0.5.8
- @backstage/theme@0.7.2
- @backstage/frontend-plugin-api@0.14.0
- @backstage/plugin-app-react@0.2.0
- @backstage/plugin-app@0.4.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.26
- @backstage/plugin-devtools@0.1.36
- @backstage/plugin-home@0.9.2
- @backstage/plugin-search@1.6.0
- @backstage/plugin-user-settings@0.9.0
- @backstage/plugin-scaffolder-react@1.19.7
- @backstage/core-plugin-api@1.12.3
- @backstage/plugin-home-react@0.1.35
- @backstage/plugin-techdocs-react@1.3.8
- @backstage/plugin-techdocs-module-addons-contrib@1.1.33
- @backstage/integration-react@1.2.15
- @backstage/plugin-kubernetes-cluster@0.0.34
- @backstage/plugin-permission-react@0.4.40
- @backstage/plugin-catalog-import@0.13.10
- @backstage/app-defaults@1.7.5
- @backstage/plugin-search-react@1.10.3
- @backstage/plugin-auth-react@0.1.24
- @backstage/plugin-signals@0.0.28
- @backstage/plugin-auth@0.1.5
- @backstage/plugin-catalog-common@1.1.8
## 0.0.32-next.2
### Patch Changes
-267
View File
@@ -1,267 +0,0 @@
app:
packages: 'all' # ✨
routes:
bindings:
catalog.viewTechDoc: techdocs.docRoot
org.catalogIndex: catalog.catalogIndex
pluginOverrides:
- match:
pluginId: pages
info:
description: 'This description was overridden in packages/app/app-config.yaml'
- match:
pluginId: /^catalog(-.*)?$/
info:
ownerEntityRefs: [cubic-belugas]
- match:
packageName: '@backstage/plugin-scaffolder'
info:
ownerEntityRefs: [cubic-belugas]
extensions:
# set availableLanguages example
- api:app/app-language:
config:
availableLanguages: ['en', 'es', 'fr', 'de', 'ja']
defaultLanguage: 'en'
- entity-card:org/members-list:
config:
showAggregateMembersToggle: true
initialRelationAggregation: aggregated
- entity-card:org/ownership:
config:
ownedKinds: ['Component', 'API', 'System']
# - apis.plugin.graphiql.browse.gitlab: true
# - graphiql-endpoint:graphiql/gitlab: true
- nav-item:search: false
- nav-item:user-settings: false
- nav-item:catalog
- nav-item:api-docs
- nav-item:scaffolder
- nav-item:app-visualizer
# Pages
- page:catalog/entity:
config:
showNavItemIcons: true
groups:
# placing a tab at the beginning
- overview:
title: Overview
# example disabling a default group
# - development: false
# example overriding a default group title
- documentation:
title: Docs
icon: docs
- deployment:
title: Deployments
# example adding a new group
- custom:
title: Custom
# Entity page cards
- entity-card:catalog/about:
config:
type: info
- entity-card:catalog/labels
- entity-card:catalog/links:
config:
filter:
kind: component
metadata.links:
$exists: true
# filter: kind:component has:links
type: info
# - entity-card:linguist/languages
- entity-card:catalog-graph/relations:
config:
height: 300
- entity-card:api-docs/has-apis
- entity-card:api-docs/consumed-apis
- entity-card:api-docs/provided-apis
- entity-card:api-docs/providing-components
- entity-card:api-docs/consuming-components
# Org Plugin
- entity-card:org/group-profile
- entity-card:org/members-list
- entity-card:org/ownership
- entity-card:org/user-profile:
config:
maxRelations: 5
hideIcons: true
# - entity-card:azure-devops/readme
# Entity page contents
- entity-content:catalog/overview:
config:
group: overview
- entity-content:api-docs/definition
- entity-content:api-docs/apis:
config:
# example associating with a default group
group: documentation
icon: kind:api
- entity-content:techdocs:
config:
group: documentation
icon: techdocs
- entity-content:kubernetes/kubernetes:
config:
# example disassociating with a default group
group: false
# - entity-content:azure-devops/pipelines
# - entity-content:azure-devops/pull-requests
# - entity-content:azure-devops/git-tags
# Enable the catalog-unprocessed-entities tab in devtools
- devtools-content:catalog-unprocessed-entities: true
# Disable the catalog-unprocessed-entities element outside devtools
- page:catalog-unprocessed-entities: false
# scmAuthExtension: >-
# createScmAuthExtension({
# id: 'apis.scmAuth.addons.ghe',
# factory({ bind }) {
# bind.scmAuthAddon({
# baseUrl: 'https://github.spotify.net',
# api: githubAuthApiRef,
# })
# }
# })
# externalRouteRefs:
# bind(catalogPlugin.externalRoutes, {
# createComponent: scaffolderPlugin.routes.root,
# viewTechDoc: techdocsPlugin.routes.docRoot,
# createFromTemplate: scaffolderPlugin.routes.selectedTemplate,
# });
# bind(apiDocsPlugin.externalRoutes, {
# registerApi: catalogImportPlugin.routes.importPage,
# });
# bind(scaffolderPlugin.externalRoutes, {
# registerComponent: catalogImportPlugin.routes.importPage,
# viewTechDoc: techdocsPlugin.routes.docRoot,
# });
# bind(orgPlugin.externalRoutes, {
# catalogIndex: catalogPlugin.routes.catalogIndex,
# });
# extensions:
# - plugin.catalog:
# config:
# externalRoutes:
# createComponent: plugin.scaffolder.page
# viewTechDoc: plugin.techdocs.docRootPage
# createFromTemplate: plugin.scaffolder.templatePage
# - graphiql.page:
# config:
# path: /
# - apis.auth.providers.github:
# config:
# provider: ghe
# - core.signInPage:
# props:
# provider:
# id: google
# title: Google
# message: Sign In using Google
# apiRef: googleAuthApiRef # ???
# - core.nav:
# config:
# logo: assets/logo.png
# layout:
# - label: Search
# icon: search
# to: /search
# items:
# - point: search
# - type: divider
# - label: Menu
# icon: menu
# items:
# - label: Home
# icon: home
# to: /catalog
# - label: Create...
# icon: create
# to: /create
# - type: divider
# - type: scroll-wrapper
# items:
# - label: Tech Radar
# icon: map
# to: /tech-radar
# - label: GraphiQL
# icon: graphiql
# to: /graphiql
# - type: divider
# - point: shortcuts
# - type: space
# - type: divider
# - label: Settings
# icon: avatar
# to: /settings
# items:
# - point: settings
# - core.nav/search: '@backstage/plugin-search#SidebarSearchModal'
# - core.nav/shortcuts:
# use: '@backstage/plugin-shortcuts#Shortcuts'
# config:
# allowExternalLinks: true
# - core.nav/settings: '@backstage/plugin-user-settings#SidebarSettings'
# - core.pages.index:
# at:
# point: core.routes
# config:
# path: /
# use: 'react-router-dom#Navigate'
# config:
# to: /catalog
# - scaffolder.page:
# config:
# groups:
# - title: Recommended
# filter:
# entity.metadata.tags: recommended
# - scaffolder.page/fields: '@backstage/plugin-scaffolder#LowerCaseValuePickerFieldExtension'
# - scaffolder.page/layout: '@backstage/plugin-scaffolder#TwoColumnLayout'
# - search.page/content: 'app#customSearchPage' # custom search page from somewhere
# - user-settings.page.routes.advanced:
# at:
# point: user-settings.page/routes
# config:
# title: Advanced
# path: /advanced
# use: '@backstage/plugin-user-settings#AdvancedSettings'
# - entity.card.orphanWarning
# - entity.card.processingErrors
# - entity.card.about
# - entity.card.catalogGraph
# - entity.card.pagerDuty
# - entity.card.links
# - entity.card.labels
# - entity.card.githubInsightsLanguages
# - entity.card.githubInsightsReleases
# - entity.card.githubInsightsReadme:
# config:
# maxHeight: 350 # Throwing this config in to have an example, but in practice rely on default
# - entity.card.subcomponentsCard
# - entity.card.userProfile
# - entity.card.ownership
# - entity.card.likeDislikeRatings
# - entity.content.dependsOnComponents
# - entity.content.codeInsights
# - entity.content.todo
# - entity.content.feedbackResponse
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "example-app",
"version": "0.0.32-next.2",
"version": "0.0.33-next.2",
"backstage": {
"role": "frontend"
},
@@ -19,7 +19,7 @@
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"start": "backstage-cli package start --config ../../app-config.yaml --config app-config.yaml",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"browserslist": {
@@ -98,7 +98,7 @@
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/jquery": "^3.3.34",
"@types/jquery": "^4.0.0",
"@types/react": "*",
"@types/react-dom": "*",
"@types/zen-observable": "^0.8.0",
+39 -28
View File
@@ -103,34 +103,45 @@ export const appModuleNav = createFrontendModule({
extensions: [
NavContentBlueprint.make({
params: {
component: ({ items }) => (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarScrollWrapper>
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<NotificationsSidebarItem />
<SidebarItem icon={BuildIcon} to="devtools" text="DevTools" />
<Settings />
</SidebarGroup>
</Sidebar>
),
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem
icon={() => item.icon}
to={item.href}
text={item.title}
/>
));
nav.take('page:home'); // Skip home
return (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<NotificationsSidebarItem />
<SidebarItem icon={BuildIcon} to="devtools" text="DevTools" />
<Settings />
</SidebarGroup>
</Sidebar>
);
},
},
}),
],
+32
View File
@@ -1,5 +1,37 @@
# @backstage/backend-app-api
## 1.6.0-next.1
### Minor Changes
- 545557a: Registration errors should be forwarded as BackendStartupResult
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
## 1.5.1-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
## 1.5.0
### Minor Changes
- f1d29b4: Added support for extension point factories, along with the ability to report module startup failures via the extension point factory context.
### Patch Changes
- 6bb2f21: Fixed memory leak by properly cleaning up process event listeners on backend shutdown.
- Updated dependencies
- @backstage/backend-plugin-api@1.7.0
## 1.5.0-next.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-app-api",
"version": "1.5.0-next.1",
"version": "1.6.0-next.1",
"description": "Core API used by Backstage backend apps",
"backstage": {
"role": "node-library"
@@ -899,7 +899,7 @@ describe('BackendInitializer', () => {
});
it('should reject duplicate plugins', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -922,13 +922,24 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(false);
});
it('should reject duplicate modules', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(testPlugin);
init.add(
createBackendModule({
@@ -954,8 +965,202 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' is already registered",
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
const modResult = plugin?.modules.find(
m =>
m.failure?.error.message ===
"Module 'mod' for plugin 'test' is already registered",
);
expect(modResult).toBeDefined();
expect(modResult?.failure?.allowed).toBe(false);
});
it('should allow other plugins to continue when one has a registration error', async () => {
const pluginAInit = jest.fn(async () => {});
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerInit({
deps: {},
init: pluginAInit,
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should have started successfully
expect(pluginAInit).toHaveBeenCalled();
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should have a registration failure
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"Plugin 'plugin-b' is already registered",
);
});
it('should permit registration errors for plugins with onPluginBootFailure: continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
plugins: { test: { onPluginBootFailure: 'continue' } },
},
},
},
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const { result } = await init.start();
const plugin = result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(true);
});
it('should attribute duplicate extension point errors to the correct plugin', async () => {
const extensionPoint = createExtensionPoint<string>({ id: 'shared-ext' });
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'a');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'b');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should succeed (registered first)
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should fail due to duplicate extension point
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"ExtensionPoint with ID 'shared-ext' is already registered",
);
});
it('should attribute invalid registration type errors to plugin when pluginId is available', async () => {
const init = new BackendInitializer(baseFactories);
// Create a fake registration with an invalid type but valid pluginId
const fakeFeature = {
$$type: '@backstage/BackendFeature' as const,
version: 'v1' as const,
featureType: 'registrations' as const,
getRegistrations: () => [
{
type: 'invalid-type',
pluginId: 'broken-plugin',
init: { deps: {}, func: async () => {} },
extensionPoints: [],
},
],
};
init.add(fakeFeature as any);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(
p => p.pluginId === 'broken-plugin',
);
expect(plugin?.failure?.error.message).toBe(
"Invalid registration type 'invalid-type'",
);
});
@@ -310,77 +310,6 @@ export class BackendInitializer {
// Initialize all root scoped services
await this.#serviceRegistry.initializeEagerServicesWithScope('root');
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
// Enumerate all registrations
for (const feature of this.#registrations) {
for (const r of feature.getRegistrations()) {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
}
}
const pluginIds = [...pluginInits.keys()];
const rootConfig = await this.#serviceRegistry.get(
coreServices.rootConfig,
'root',
@@ -390,15 +319,32 @@ export class BackendInitializer {
'root',
);
const allRegistrations = this.#registrations.flatMap(f =>
f.getRegistrations(),
);
const allPluginIds = [
...new Set(
allRegistrations.flatMap(r =>
'pluginId' in r && typeof r.pluginId === 'string' ? [r.pluginId] : [],
),
),
];
const resultCollector = createInitializationResultCollector({
pluginIds,
pluginIds: allPluginIds,
logger: rootLogger,
allowBootFailurePredicate: createAllowBootFailurePredicate(rootConfig),
});
const { pluginInits, moduleInits } = this.#enumerateRegistrations(
allRegistrations,
resultCollector,
);
// All plugins are initialized in parallel
await Promise.all(
pluginIds.map(async pluginId => {
[...pluginInits.keys()].map(async pluginId => {
try {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
@@ -491,6 +437,104 @@ export class BackendInitializer {
return { result };
}
#enumerateRegistrations(
allRegistrations: ReturnType<
InternalBackendRegistrations['getRegistrations']
>,
resultCollector: ReturnType<typeof createInitializationResultCollector>,
): {
pluginInits: Map<string, BackendRegisterInit>;
moduleInits: Map<string, Map<string, BackendRegisterInit>>;
} {
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
for (const r of allRegistrations) {
const addedExtensionPointIds: string[] = [];
try {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
addedExtensionPointIds.push(extRef.id);
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
addedExtensionPointIds.push(extReg.extensionPoint.id);
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
} catch (error: unknown) {
assertError(error);
// Clean up partially registered extension points
for (const id of addedExtensionPointIds) {
this.#extensionPoints.delete(id);
}
if ('pluginId' in r && 'moduleId' in r) {
resultCollector.onPluginModuleResult(r.pluginId, r.moduleId, error);
} else if ('pluginId' in r) {
pluginInits.delete(r.pluginId);
moduleInits.delete(r.pluginId);
resultCollector.onPluginResult(r.pluginId, error);
} else {
throw error;
}
}
}
return { pluginInits, moduleInits };
}
// It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit
async stop(): Promise<void> {
instanceRegistry.unregister(this);
+86
View File
@@ -1,5 +1,91 @@
# @backstage/backend-defaults
## 0.16.0-next.2
### Patch Changes
- 015668c: Added `cancelTask` method to the `SchedulerService` interface and implementation, allowing cancellation of currently running scheduled tasks. For global tasks, the database lock is released and a periodic liveness check aborts the running task function. For local tasks, the task's abort signal is triggered directly. A new `POST /.backstage/scheduler/v1/tasks/:id/cancel` endpoint is also available.
- 5fcbef2: Updated dependency `express-rate-limit` to `^8.0.0`.
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
- @backstage/integration@2.0.0-next.2
- @backstage/backend-app-api@1.6.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.2
- @backstage/plugin-events-node@0.4.20-next.1
- @backstage/plugin-permission-node@0.10.11-next.1
## 0.16.0-next.1
### Minor Changes
- 0e7d8f9: The scheduler service now uses the metrics service to create metrics, providing plugin-scoped attribution.
- 527cf88: **BREAKING** Removed deprecated `BitbucketUrlReader`. Use the `BitbucketCloudUrlReader` or the `BitbucketServerUrlReader` instead.
### Patch Changes
- 62f0a53: Fixed error forwarding in the actions registry so that known errors like `InputError` and `NotFoundError` thrown by actions preserve their original status codes and messages instead of being wrapped in `ForwardedError` and coerced to 500.
- Updated dependencies
- @backstage/cli-node@0.2.19-next.1
- @backstage/integration@2.0.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/config-loader@1.10.9-next.0
- @backstage/errors@1.2.7
- @backstage/integration-aws-node@0.1.20
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-node@0.10.11-next.0
## 0.15.3-next.0
### Patch Changes
- 6738cf0: build(deps): bump `minimatch` from 9.0.5 to 10.2.1
- d933f62: Add configurable throttling and retry mechanism for GitLab integration.
- b99158a: Fixed `yarn backstage-cli config:check --strict --config app-config.yaml` config validation error by adding
an optional `default` type discriminator to PostgreSQL connection configuration,
allowing `config:check` to properly validate `default` connection configurations.
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-node@0.2.19-next.0
- @backstage/integration@1.21.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration-aws-node@0.1.20
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-node@0.10.11-next.0
## 0.15.2
### Patch Changes
- 7455dae: Use node prefix on native imports
- 44f5d04: Minor internal restructure of the postgres config loading code
- 4fc7bf0: Bump to tar v7
- 5dd683f: `createRateLimitMiddleware` is now exported from `@backstage/backend-defaults/httpRouter`
- 8dd518a: Support `connection.type: azure` in database client to use Microsoft Entra authentication with Azure database for PostgreSQL
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/backend-app-api@1.5.0
- @backstage/integration@1.20.0
- @backstage/integration-aws-node@0.1.20
- @backstage/backend-plugin-api@1.7.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/config-loader@1.10.8
- @backstage/cli-node@0.2.18
- @backstage/plugin-auth-node@0.6.13
- @backstage/plugin-permission-node@0.10.10
- @backstage/plugin-events-node@0.4.19
## 0.15.2-next.1
### Patch Changes
+34
View File
@@ -633,6 +633,10 @@ export interface Config {
ipAddressType?: 'PUBLIC' | 'PRIVATE' | 'PSC';
}
| {
/**
* The rest config for default, regular connections
*/
type?: 'default';
/**
* Password that belongs to the client User
* @visibility secret
@@ -1123,6 +1127,36 @@ export interface Config {
headers?: { [name: string]: string };
};
/**
* Options for the metrics service.
*/
metrics?: {
/**
* Plugin-specific metrics configuration. Each plugin can override meter metadata.
*/
plugin?: {
[pluginId: string]: {
/**
* Meter configuration for this plugin.
*/
meter?: {
/**
* Custom meter name. If not set, defaults to backstage-plugin-{pluginId}.
*/
name?: string;
/**
* Version for the meter.
*/
version?: string;
/**
* Schema URL for the meter.
*/
schemaUrl?: string;
};
};
};
};
/**
* Options to configure the default RootLoggerService.
*/
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-defaults",
"version": "0.15.2-next.1",
"version": "0.16.0-next.2",
"description": "Backend defaults used by Backstage backend apps",
"backstage": {
"role": "node-library"
@@ -144,6 +144,7 @@
"@backstage/integration-aws-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
"@google-cloud/storage": "^7.0.0",
@@ -164,7 +165,7 @@
"cron": "^3.0.0",
"express": "^4.22.0",
"express-promise-router": "^4.1.0",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^8.2.2",
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
@@ -176,7 +177,7 @@
"lodash": "^4.17.21",
"logform": "^2.3.2",
"luxon": "^3.0.0",
"minimatch": "^9.0.0",
"minimatch": "^10.2.1",
"mysql2": "^3.0.0",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.2",
@@ -5,6 +5,7 @@
```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
@@ -22,6 +23,13 @@ export const actionsServiceFactory: ServiceFactory<
'singleton'
>;
// @alpha
export const metricsServiceFactory: ServiceFactory<
MetricsService,
'plugin',
'singleton'
>;
// @alpha
export const rootSystemMetadataServiceFactory: ServiceFactory<
RootSystemMetadataService,
@@ -6,6 +6,7 @@
import { DatabaseService } from '@backstage/backend-plugin-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
@@ -17,6 +18,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -10,7 +10,6 @@ import { AzureCredentialsManager } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BitbucketCloudIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
import { BitbucketServerIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import { GerritIntegration } from '@backstage/integration';
@@ -190,38 +189,6 @@ export class BitbucketServerUrlReader implements UrlReaderService {
toString(): string;
}
// @public @deprecated
export class BitbucketUrlReader implements UrlReaderService {
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse>;
// (undocumented)
readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse>;
// (undocumented)
search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export class FetchUrlReader implements UrlReaderService {
static factory: ReaderFactory;
@@ -38,6 +38,7 @@ import { eventsServiceFactory } from '@backstage/plugin-events-node';
import {
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
} from '@backstage/backend-defaults/alpha';
import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory';
@@ -66,6 +67,7 @@ export const defaultServiceFactories = [
// alpha services
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
// Unexported alpha services kept around for compatibility reasons
instanceMetadataServiceFactory,
@@ -66,6 +66,7 @@ describe('actionsServiceFactory', () => {
const mockActionsDefinition: ActionsServiceAction = {
description: 'my mock description',
id: 'my-plugin:test',
pluginId: 'my-plugin',
name: 'testy',
title: 'Test',
schema: {
@@ -755,6 +756,7 @@ describe('actionsServiceFactory', () => {
{
description: 'Test',
id: 'plugin-with-action:with-validation',
pluginId: 'plugin-with-action',
name: 'with-validation',
schema: {
input: {
@@ -16,8 +16,11 @@
import {
AuthService,
BackstageCredentials,
HttpAuthService,
LoggerService,
PermissionsRegistryService,
PermissionsService,
PluginMetadataService,
} from '@backstage/backend-plugin-api';
import PromiseRouter from 'express-promise-router';
@@ -28,12 +31,10 @@ import {
ActionsRegistryActionOptions,
ActionsRegistryService,
} from '@backstage/backend-plugin-api/alpha';
import {
ForwardedError,
InputError,
NotAllowedError,
NotFoundError,
} from '@backstage/errors';
import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
type ActionEntry = [string, ActionsRegistryActionOptions<any, any>];
export class DefaultActionsRegistryService implements ActionsRegistryService {
private actions: Map<string, ActionsRegistryActionOptions<any, any>> =
@@ -43,17 +44,23 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
private readonly httpAuth: HttpAuthService;
private readonly auth: AuthService;
private readonly metadata: PluginMetadataService;
private readonly permissions: PermissionsService;
private readonly permissionsRegistry: PermissionsRegistryService;
private constructor(
logger: LoggerService,
httpAuth: HttpAuthService,
auth: AuthService,
metadata: PluginMetadataService,
permissions: PermissionsService,
permissionsRegistry: PermissionsRegistryService,
) {
this.logger = logger;
this.httpAuth = httpAuth;
this.auth = auth;
this.metadata = metadata;
this.permissions = permissions;
this.permissionsRegistry = permissionsRegistry;
}
static create({
@@ -61,24 +68,46 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
logger,
auth,
metadata,
permissions,
permissionsRegistry,
}: {
httpAuth: HttpAuthService;
logger: LoggerService;
auth: AuthService;
metadata: PluginMetadataService;
permissions: PermissionsService;
permissionsRegistry: PermissionsRegistryService;
}): DefaultActionsRegistryService {
return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata);
return new DefaultActionsRegistryService(
logger,
httpAuth,
auth,
metadata,
permissions,
permissionsRegistry,
);
}
createRouter(): Router {
const router = PromiseRouter();
router.use(json());
router.get('/.backstage/actions/v1/actions', (_, res) => {
router.get('/.backstage/actions/v1/actions', async (req, res) => {
const credentials = await this.httpAuth.credentials(req);
const entries = Array.from(this.actions.entries());
const allowedActions = await this.filterByPermissions(
entries,
credentials,
);
return res.json({
actions: Array.from(this.actions.entries()).map(([id, action]) => ({
actions: allowedActions.map(([id, action]) => ({
id,
...action,
name: action.name,
title: action.title,
description: action.description,
pluginId: this.metadata.getId(),
attributes: {
// Inspired by the @modelcontextprotocol/sdk defaults for the hints.
// https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812
@@ -120,6 +149,18 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
throw new NotFoundError(`Action "${req.params.actionId}" not found`);
}
if (action.visibilityPermission) {
const [decision] = await this.permissions.authorize(
[{ permission: action.visibilityPermission }],
{ credentials },
);
if (decision.result !== AuthorizeResult.ALLOW) {
throw new NotFoundError(
`Action "${req.params.actionId}" not found`,
);
}
}
const input = action.schema?.input
? action.schema.input(z).safeParse(req.body)
: ({ success: true, data: undefined } as const);
@@ -131,31 +172,24 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
);
}
try {
const result = await action.action({
input: input.data,
credentials,
logger: this.logger,
});
const result = await action.action({
input: input.data,
credentials,
logger: this.logger,
});
const output = action.schema?.output
? action.schema.output(z).safeParse(result?.output)
: ({ success: true, data: result?.output } as const);
const output = action.schema?.output
? action.schema.output(z).safeParse(result?.output)
: ({ success: true, data: result?.output } as const);
if (!output.success) {
throw new InputError(
`Invalid output from action "${req.params.actionId}"`,
output.error,
);
}
res.json({ output: output.data });
} catch (error) {
throw new ForwardedError(
`Failed execution of action "${req.params.actionId}"`,
error,
if (!output.success) {
throw new InputError(
`Invalid output from action "${req.params.actionId}"`,
output.error,
);
}
res.json({ output: output.data });
},
);
return router;
@@ -171,6 +205,38 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
throw new Error(`Action with id "${id}" is already registered`);
}
if (options.visibilityPermission) {
this.permissionsRegistry.addPermissions([options.visibilityPermission]);
}
this.actions.set(id, options);
}
private async filterByPermissions(
entries: ActionEntry[],
credentials: BackstageCredentials,
): Promise<ActionEntry[]> {
const permissionedEntries = entries.filter(
([_, action]) => action.visibilityPermission,
);
if (permissionedEntries.length === 0) {
return entries;
}
const decisions = await this.permissions.authorize(
permissionedEntries.map(([_, action]) => ({
permission: action.visibilityPermission!,
})),
{ credentials },
);
const deniedIds = new Set(
permissionedEntries
.filter((_, index) => decisions[index].result !== AuthorizeResult.ALLOW)
.map(([id]) => id),
);
return entries.filter(([id]) => !deniedIds.has(id));
}
}
@@ -22,8 +22,12 @@ import {
import { httpRouterServiceFactory } from '../../../entrypoints/httpRouter';
import request from 'supertest';
import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory';
import { InputError } from '@backstage/errors';
import { InputError, NotFoundError } from '@backstage/errors';
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
import {
AuthorizeResult,
createPermission,
} from '@backstage/plugin-permission-common';
describe('actionsRegistryServiceFactory', () => {
const defaultServices = [
@@ -510,7 +514,7 @@ describe('actionsRegistryServiceFactory', () => {
expect(body).toMatchObject({ output: { ok: true } });
});
it('should return the error from the action if it throws', async () => {
it('should forward the original error when the action throws a known error', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
@@ -528,11 +532,341 @@ describe('actionsRegistryServiceFactory', () => {
expect(status).toBe(400);
expect(body).toMatchObject({
error: {
message: expect.stringContaining(
'Failed execution of action "my-plugin:test"',
),
name: 'InputError',
message: 'test',
},
});
});
it('should forward a NotFoundError from the action with 404 status', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
mockAction.mockRejectedValue(new NotFoundError('entity not found'));
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(status).toBe(404);
expect(body).toMatchObject({
error: {
name: 'NotFoundError',
message: 'entity not found',
},
});
});
});
describe('permissions', () => {
const testPermission = createPermission({
name: 'test.action.use',
attributes: {},
});
it('should filter out actions with denied permissions when listing', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'public-action',
title: 'Public Action',
description: 'No permission required',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
mockServices.permissions.factory({
result: AuthorizeResult.DENY,
}),
],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body.actions).toHaveLength(1);
expect(body.actions[0].name).toBe('public-action');
});
it('should include actions with allowed permissions when listing', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
mockServices.permissions.factory({
result: AuthorizeResult.ALLOW,
}),
],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body.actions).toHaveLength(1);
expect(body.actions[0].name).toBe('protected-action');
});
it('should return 404 when invoking an action with denied permission', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
mockServices.permissions.factory({
result: AuthorizeResult.DENY,
}),
],
});
const { body, status } = await request(server).post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:protected-action/invoke',
);
expect(status).toBe(404);
expect(body).toMatchObject({
error: {
message: 'Action "my-plugin:protected-action" not found',
},
});
});
it('should allow invoking an action when permission is granted', async () => {
const mockAction = jest.fn().mockResolvedValue({ output: { ok: true } });
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({ ok: z.boolean() }),
},
action: mockAction,
});
},
});
},
});
const { server } = await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
mockServices.permissions.factory({
result: AuthorizeResult.ALLOW,
}),
],
});
const { body, status } = await request(server).post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:protected-action/invoke',
);
expect(status).toBe(200);
expect(body).toMatchObject({ output: { ok: true } });
expect(mockAction).toHaveBeenCalled();
});
it('should pass the correct permission to the authorize call', async () => {
const permissionsMock = mockServices.permissions.mock({
authorize: async () => [{ result: AuthorizeResult.ALLOW }],
});
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
permissionsMock.factory,
],
});
await request(server).get('/api/my-plugin/.backstage/actions/v1/actions');
expect(permissionsMock.authorize).toHaveBeenCalledWith(
[{ permission: testPermission }],
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should register the permission with the permissions registry', async () => {
const permissionsRegistryMock = mockServices.permissionsRegistry.mock();
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: actionsRegistryServiceRef,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'protected-action',
title: 'Protected Action',
description: 'Permission required',
visibilityPermission: testPermission,
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
},
});
},
});
await startTestBackend({
features: [
pluginSubject,
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
permissionsRegistryMock.factory,
],
});
expect(permissionsRegistryMock.addPermissions).toHaveBeenCalledWith([
testPermission,
]);
});
});
});
@@ -32,13 +32,25 @@ export const actionsRegistryServiceFactory = createServiceFactory({
httpAuth: coreServices.httpAuth,
logger: coreServices.logger,
auth: coreServices.auth,
permissions: coreServices.permissions,
permissionsRegistry: coreServices.permissionsRegistry,
},
factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => {
factory: ({
metadata,
httpRouter,
httpAuth,
logger,
auth,
permissions,
permissionsRegistry,
}) => {
const actionsRegistryService = DefaultActionsRegistryService.create({
httpAuth,
logger,
auth,
metadata,
permissions,
permissionsRegistry,
});
httpRouter.use(actionsRegistryService.createRouter());
@@ -0,0 +1,117 @@
/*
* Copyright 2026 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 { metrics } from '@opentelemetry/api';
import { DefaultMetricsService } from './DefaultMetricsService';
const mockGetMeter = jest.spyOn(metrics, 'getMeter');
describe('DefaultMetricsService', () => {
beforeEach(() => {
mockGetMeter.mockClear();
});
describe('create', () => {
it('should create a MetricsService with name only', () => {
const service = DefaultMetricsService.create({ name: 'test-meter' });
expect(mockGetMeter).toHaveBeenCalledTimes(1);
expect(mockGetMeter).toHaveBeenCalledWith('test-meter', undefined, {
schemaUrl: undefined,
});
expect(service).toBeDefined();
});
it('should create a MetricsService with name, version, and schemaUrl', () => {
const service = DefaultMetricsService.create({
name: 'test-meter',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
});
expect(mockGetMeter).toHaveBeenCalledTimes(1);
expect(mockGetMeter).toHaveBeenCalledWith('test-meter', '1.2.3', {
schemaUrl: 'https://example.com/schema',
});
expect(service).toBeDefined();
});
});
describe('metric instruments', () => {
it('should create a counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createCounter('my_counter', {
description: 'A test counter',
unit: 'bytes',
});
expect(counter).toBeDefined();
expect(counter.add).toBeDefined();
});
it('should create an up-down counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const upDownCounter = service.createUpDownCounter('my_updown');
expect(upDownCounter).toBeDefined();
expect(upDownCounter.add).toBeDefined();
});
it('should create a histogram', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const histogram = service.createHistogram('my_histogram');
expect(histogram).toBeDefined();
expect(histogram.record).toBeDefined();
});
it('should create a gauge', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const gauge = service.createGauge('my_gauge');
expect(gauge).toBeDefined();
expect(gauge.record).toBeDefined();
});
it('should create an observable counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createObservableCounter('my_observable_counter');
expect(counter).toBeDefined();
expect(counter.addCallback).toBeDefined();
expect(counter.removeCallback).toBeDefined();
});
it('should create an observable up-down counter', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const counter = service.createObservableUpDownCounter(
'my_observable_updown',
);
expect(counter).toBeDefined();
expect(counter.addCallback).toBeDefined();
});
it('should create an observable gauge', () => {
const service = DefaultMetricsService.create({ name: 'test' });
const gauge = service.createObservableGauge('my_observable_gauge');
expect(gauge).toBeDefined();
expect(gauge.addCallback).toBeDefined();
});
});
});
@@ -0,0 +1,123 @@
/*
* Copyright 2026 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 { Meter, metrics } from '@opentelemetry/api';
import {
MetricsService,
MetricAttributes,
MetricOptions,
MetricsServiceCounter,
MetricsServiceUpDownCounter,
MetricsServiceHistogram,
MetricsServiceGauge,
MetricsServiceObservableCounter,
MetricsServiceObservableGauge,
MetricsServiceObservableUpDownCounter,
} from '@backstage/backend-plugin-api/alpha';
/**
* Options for creating a {@link DefaultMetricsService}.
*
* @alpha
*/
export interface DefaultMetricsServiceOptions {
name: string;
version?: string;
schemaUrl?: string;
}
/**
* Default implementation of the {@link MetricsService} interface.
*
* This implementation provides a thin wrapper around the OpenTelemetry Meter API.
*
* @alpha
*/
export class DefaultMetricsService implements MetricsService {
private readonly meter: Meter;
private constructor(opts: DefaultMetricsServiceOptions) {
// The meter name sets the OpenTelemetry Instrumentation Scope which identifies the source of metrics in telemetry backends.
this.meter = metrics.getMeter(opts.name, opts.version, {
schemaUrl: opts.schemaUrl,
});
}
/**
* Creates a new {@link MetricsService} instance.
*
* @param opts - Options for configuring the meter scope
* @returns A new MetricsService instance
*/
static create(opts: DefaultMetricsServiceOptions): MetricsService {
return new DefaultMetricsService(opts);
}
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes> {
return this.meter.createCounter(name, opts);
}
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes> {
return this.meter.createUpDownCounter(name, opts);
}
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes> {
return this.meter.createHistogram(name, opts);
}
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes> {
return this.meter.createGauge(name, opts);
}
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes> {
return this.meter.createObservableCounter(name, opts);
}
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes> {
return this.meter.createObservableUpDownCounter(name, opts);
}
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes> {
return this.meter.createObservableGauge(name, opts);
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2026 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 { metricsServiceFactory } from './metricsServiceFactory';
@@ -0,0 +1,130 @@
/*
* Copyright 2026 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 {
mockServices,
ServiceFactoryTester,
} from '@backstage/backend-test-utils';
import { metricsServiceFactory } from './metricsServiceFactory';
import { DefaultMetricsService } from './DefaultMetricsService';
describe('metricsServiceFactory', () => {
let createSpy: jest.SpyInstance;
beforeEach(() => {
createSpy = jest.spyOn(DefaultMetricsService, 'create');
});
afterEach(() => {
jest.restoreAllMocks();
});
const defaultServices = [
mockServices.rootConfig.factory(),
metricsServiceFactory,
];
it('should use backstage-plugin-{pluginId} as meter name when no config is set', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: defaultServices,
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'backstage-plugin-my-plugin',
version: undefined,
schemaUrl: undefined,
});
});
it('should use custom name from config', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
metrics: {
plugin: {
'my-plugin': {
meter: {
name: 'custom-metrics-name',
},
},
},
},
},
},
}),
metricsServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'custom-metrics-name',
version: undefined,
schemaUrl: undefined,
});
});
it('should accept version and schemaUrl from config', async () => {
await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
metrics: {
plugin: {
'my-plugin': {
meter: {
name: 'my-plugin-metrics',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
},
},
},
},
},
},
}),
metricsServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'my-plugin-metrics',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
});
});
it('should implement the full MetricsService interface', async () => {
const subject = await ServiceFactoryTester.from(metricsServiceFactory, {
dependencies: defaultServices,
}).getSubject('test-plugin');
expect(createSpy).toHaveBeenCalledWith({
name: 'backstage-plugin-test-plugin',
version: undefined,
schemaUrl: undefined,
});
expect(subject.createCounter).toBeDefined();
expect(subject.createUpDownCounter).toBeDefined();
expect(subject.createHistogram).toBeDefined();
expect(subject.createGauge).toBeDefined();
expect(subject.createObservableCounter).toBeDefined();
expect(subject.createObservableUpDownCounter).toBeDefined();
expect(subject.createObservableGauge).toBeDefined();
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2025 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 { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultMetricsService } from './DefaultMetricsService';
/**
* Service factory for collecting plugin-scoped metrics.
*
* @alpha
*/
export const metricsServiceFactory = createServiceFactory({
service: metricsServiceRef,
deps: {
config: coreServices.rootConfig,
pluginMetadata: coreServices.pluginMetadata,
},
factory: ({ config, pluginMetadata }) => {
const pluginId = pluginMetadata.getId();
const meterConfig = config.getOptionalConfig(
`backend.metrics.plugin.${pluginId}.meter`,
);
const scopeName = `backstage-plugin-${pluginId}`;
const name = meterConfig?.getOptionalString('name') ?? scopeName;
const version = meterConfig?.getOptionalString('version');
const schemaUrl = meterConfig?.getOptionalString('schemaUrl');
return DefaultMetricsService.create({ name, version, schemaUrl });
},
});
@@ -16,4 +16,5 @@
export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { metricsServiceFactory } from './entrypoints/metrics';
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
@@ -20,6 +20,7 @@ import waitForExpect from 'wait-for-expect';
import { DefaultSchedulerService } from './DefaultSchedulerService';
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -32,6 +33,7 @@ describe('TaskScheduler', () => {
getId: () => 'test',
} satisfies PluginMetadataService;
const testScopedSignal = createTestScopedSignal();
const metrics = metricsServiceMock.mock();
it.each(databases.eachSupportedId())(
'can return a working v1 plugin impl, %p',
@@ -42,6 +44,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -71,6 +74,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -27,6 +27,7 @@ import { Duration } from 'luxon';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
/**
* Default implementation of the task scheduler service.
@@ -37,6 +38,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -67,6 +69,7 @@ export class DefaultSchedulerService {
options.pluginMetadata.getId(),
databaseFactory,
options.logger,
options.metrics,
options.rootLifecycle,
);
@@ -16,6 +16,7 @@
import { LocalTaskWorker } from './LocalTaskWorker';
import { mockServices } from '@backstage/backend-test-utils';
import { ConflictError } from '@backstage/errors';
import waitFor from 'wait-for-expect';
jest.setTimeout(10_000);
@@ -110,6 +111,54 @@ describe('LocalTaskWorker', () => {
controller.abort();
});
it('can cancel a running task', async () => {
let receivedSignal: AbortSignal | undefined;
const fn = jest.fn(async (signal: AbortSignal) => {
receivedSignal = signal;
await new Promise(r => setTimeout(r, 5000));
});
const controller = new AbortController();
const worker = new LocalTaskWorker('a', fn, logger);
worker.start(
{
version: 2,
cadence: 'PT10S',
timeoutAfterDuration: 'PT10S',
},
{ signal: controller.signal },
);
await waitFor(() => {
expect(fn).toHaveBeenCalledTimes(1);
});
expect(receivedSignal?.aborted).toBe(false);
worker.cancel();
expect(receivedSignal?.aborted).toBe(true);
controller.abort();
});
it('cannot cancel a task that is not running', async () => {
const fn = jest.fn();
const controller = new AbortController();
const worker = new LocalTaskWorker('a', fn, logger);
worker.start(
{
version: 2,
initialDelayDuration: 'PT1000S',
cadence: 'PT10S',
timeoutAfterDuration: 'PT10S',
},
{ signal: controller.signal },
);
expect(() => worker.cancel()).toThrow(ConflictError);
controller.abort();
});
it('goes through the expected states', async () => {
const fn = jest
.fn()
@@ -29,6 +29,7 @@ import { delegateAbortController, serializeError, sleep } from './util';
*/
export class LocalTaskWorker {
private abortWait: AbortController | undefined;
private taskAbortController: AbortController | undefined;
#taskState: Exclude<TaskApiTasksResponse['taskState'], null> = {
status: 'idle',
};
@@ -93,6 +94,13 @@ export class LocalTaskWorker {
this.abortWait.abort();
}
cancel(): void {
if (!this.taskAbortController) {
throw new ConflictError(`Task ${this.taskId} is not running`);
}
this.taskAbortController.abort();
}
taskState(): TaskApiTasksResponse['taskState'] {
return this.#taskState;
}
@@ -134,10 +142,10 @@ export class LocalTaskWorker {
): Promise<void> {
// Abort the task execution either if the worker is stopped, or if the
// task timeout is hit
const taskAbortController = delegateAbortController(signal);
this.taskAbortController = delegateAbortController(signal);
const timeoutDuration = Duration.fromISO(settings.timeoutAfterDuration);
const timeoutHandle = setTimeout(() => {
taskAbortController.abort();
this.taskAbortController?.abort();
}, timeoutDuration.as('milliseconds'));
this.#taskState = {
@@ -152,7 +160,7 @@ export class LocalTaskWorker {
};
try {
await this.fn(taskAbortController.signal);
await this.fn(this.taskAbortController.signal);
this.#taskState.lastRunEndedAt = DateTime.utc().toISO()!;
this.#taskState.lastRunError = undefined;
} catch (e) {
@@ -162,7 +170,8 @@ export class LocalTaskWorker {
// release resources
clearTimeout(timeoutHandle);
taskAbortController.abort();
this.taskAbortController.abort();
this.taskAbortController = undefined;
}
/**
@@ -27,6 +27,7 @@ import {
parseDuration,
} from './PluginTaskSchedulerImpl';
import { createDeferred } from '@backstage/types';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -56,6 +57,7 @@ describe('PluginTaskManagerImpl', () => {
'myplugin',
async () => knex,
mockServices.logger.mock(),
metricsServiceMock.mock(),
{
addShutdownHook,
addBeforeShutdownHook: jest.fn(),
@@ -397,6 +399,100 @@ describe('PluginTaskManagerImpl', () => {
);
});
describe('cancelTask with local scope', () => {
it('can cancel a running task', async () => {
const { manager } = await init('SQLITE_3');
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
}, 60_000);
it('cannot cancel a task that is not running', 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 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(ConflictError);
}, 60_000);
});
describe('cancelTask with global scope', () => {
it.each(databases.eachSupportedId())(
'can cancel a running task, %p',
async databaseId => {
const { manager } = await init(databaseId);
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'global',
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a non-existent task, %p',
async databaseId => {
const { manager } = await init(databaseId);
await expect(manager.cancelTask('nonexistent')).rejects.toThrow(
NotFoundError,
);
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a task that is not running, %p',
async databaseId => {
const { manager } = await init(databaseId);
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn: jest.fn(),
scope: 'global',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(
ConflictError,
);
},
);
});
describe('parseDuration', () => {
it('should parse durations', () => {
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
@@ -24,7 +24,13 @@ import {
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Counter, Histogram, Gauge, metrics, trace } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import {
MetricsService,
MetricsServiceCounter,
MetricsServiceGauge,
MetricsServiceHistogram,
} from '@backstage/backend-plugin-api/alpha';
import { Knex } from 'knex';
import { Duration } from 'luxon';
import express from 'express';
@@ -45,10 +51,10 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
private readonly allScheduledTasks: SchedulerServiceTaskDescriptor[] = [];
private readonly shutdownInitiated: Promise<boolean>;
private readonly counter: Counter;
private readonly duration: Histogram;
private readonly lastStarted: Gauge;
private readonly lastCompleted: Gauge;
private readonly counter: MetricsServiceCounter;
private readonly duration: MetricsServiceHistogram;
private readonly lastStarted: MetricsServiceGauge;
private readonly lastCompleted: MetricsServiceGauge;
private readonly pluginId: string;
private readonly databaseFactory: () => Promise<Knex>;
@@ -58,24 +64,27 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
pluginId: string,
databaseFactory: () => Promise<Knex>,
logger: LoggerService,
metrics: MetricsService,
rootLifecycle: RootLifecycleService,
) {
this.pluginId = pluginId;
this.databaseFactory = databaseFactory;
this.logger = logger;
const meter = metrics.getMeter('default');
this.counter = meter.createCounter('backend_tasks.task.runs.count', {
this.counter = metrics.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',
});
this.lastStarted = meter.createGauge('backend_tasks.task.runs.started', {
this.duration = metrics.createHistogram(
'backend_tasks.task.runs.duration',
{
description: 'Histogram of task run durations',
unit: 'seconds',
},
);
this.lastStarted = metrics.createGauge('backend_tasks.task.runs.started', {
description: 'Epoch timestamp seconds when the task was last started',
unit: 'seconds',
});
this.lastCompleted = meter.createGauge(
this.lastCompleted = metrics.createGauge(
'backend_tasks.task.runs.completed',
{
description: 'Epoch timestamp seconds when the task was last completed',
@@ -98,6 +107,17 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
await TaskWorker.trigger(knex, id);
}
async cancelTask(id: string): Promise<void> {
const localTask = this.localWorkersById.get(id);
if (localTask) {
localTask.cancel();
return;
}
const knex = await this.databaseFactory();
await TaskWorker.cancel(knex, id);
}
async scheduleTask(
task: SchedulerServiceTaskScheduleDefinition &
SchedulerServiceTaskInvocationDefinition,
@@ -197,6 +217,15 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
},
);
router.post(
'/.backstage/scheduler/v1/tasks/:id/cancel',
async (req, res) => {
const { id } = req.params;
await this.cancelTask(id);
res.status(200).end();
},
);
return router;
}
@@ -15,6 +15,7 @@
*/
import { TestDatabases, mockServices } from '@backstage/backend-test-utils';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { DateTime, Duration } from 'luxon';
import waitForExpect from 'wait-for-expect';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
@@ -584,4 +585,79 @@ describe('TaskWorker', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'can cancel a running task, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV2 = {
version: 2,
cadence: '* * * * * *',
initialDelayDuration: undefined,
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await worker.tryClaimTask('ticket', settings);
// Verify the task is running
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row.current_run_ticket).toBe('ticket');
await TaskWorker.cancel(knex, 'task1');
// Verify the task is now idle with a cancellation error recorded
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row.current_run_ticket).toBeNull();
expect(row.current_run_started_at).toBeNull();
expect(row.current_run_expires_at).toBeNull();
expect(row.last_run_ended_at).not.toBeNull();
expect(row.last_run_error_json).toContain('Task was cancelled');
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a non-existent task, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
await expect(TaskWorker.cancel(knex, 'nonexistent')).rejects.toThrow(
NotFoundError,
);
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a task that is not running, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV2 = {
version: 2,
cadence: '* * * * * *',
initialDelayDuration: undefined,
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await expect(TaskWorker.cancel(knex, 'task1')).rejects.toThrow(
ConflictError,
);
await knex.destroy();
},
);
});
@@ -152,6 +152,36 @@ export class TaskWorker {
}
}
static async cancel(knex: Knex, taskId: string): Promise<void> {
const [row] = await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', taskId)
.select('settings_json', 'current_run_ticket');
if (!row) {
throw new NotFoundError(`Task ${taskId} does not exist`);
}
if (!row.current_run_ticket) {
throw new ConflictError(`Task ${taskId} is not running`);
}
const settings = taskSettingsV2Schema.parse(JSON.parse(row.settings_json));
const nextRun = TaskWorker.computeNextRunStartAt(knex, settings);
const updatedRows = await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', taskId)
.where('current_run_ticket', '=', row.current_run_ticket)
.update({
next_run_start_at: nextRun,
current_run_ticket: knex.raw('null'),
current_run_started_at: knex.raw('null'),
current_run_expires_at: knex.raw('null'),
last_run_ended_at: knex.fn.now(),
last_run_error_json: serializeError(new Error('Task was cancelled')),
});
if (updatedRows < 1) {
throw new ConflictError(`Task ${taskId} is not running`);
}
}
static async taskStates(
knex: Knex,
): Promise<Map<string, TaskApiTasksResponse['taskState']>> {
@@ -227,11 +257,22 @@ export class TaskWorker {
}
// Abort the task execution either if the worker is stopped, or if the
// task timeout is hit
// task timeout is hit, or if the task ticket was lost (e.g. due to
// cancellation from another host)
const taskAbortController = delegateAbortController(signal);
const timeoutHandle = setTimeout(() => {
taskAbortController.abort();
}, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds'));
let livenessHandle: ReturnType<typeof setTimeout> | undefined;
const scheduleLivenessCheck = () => {
livenessHandle = setTimeout(async () => {
await this.checkLiveness(ticket, taskAbortController);
if (!taskAbortController.signal.aborted) {
scheduleLivenessCheck();
}
}, this.workCheckFrequency.as('milliseconds'));
};
scheduleLivenessCheck();
try {
this.#workerState = {
@@ -248,6 +289,7 @@ export class TaskWorker {
status: 'idle',
};
clearTimeout(timeoutHandle);
clearTimeout(livenessHandle);
}
await this.tryReleaseTask(ticket, taskSettings);
@@ -283,7 +325,7 @@ export class TaskWorker {
// We make a conversion here to make typescript happy, because the luxon versions of the cron library and here may not be the same
const timeConverted = DateTime.fromJSDate(time.toJSDate());
nextStartAt = this.nextRunAtRaw(timeConverted);
nextStartAt = TaskWorker.nextRunAtRaw(this.knex, timeConverted);
startAt ||= nextStartAt;
} else if (isManual) {
nextStartAt = this.knex.raw('null');
@@ -334,6 +376,33 @@ export class TaskWorker {
);
}
/**
* Checks whether the current task ticket is still valid in the database.
* If the ticket has been cleared (e.g. by cancellation or janitor cleanup),
* aborts the task execution.
*/
private async checkLiveness(
ticket: string,
taskAbortController: AbortController,
): Promise<void> {
try {
const [row] = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.select('current_run_ticket');
if (!row || row.current_run_ticket !== ticket) {
this.logger.info(
`Task ticket for "${this.taskId}" is no longer valid; aborting execution`,
);
taskAbortController.abort();
}
} catch (e) {
this.logger.warn(
`Failed to check liveness for task "${this.taskId}", ${e}`,
);
}
}
/**
* Check if the task is ready to run
*/
@@ -407,48 +476,49 @@ export class TaskWorker {
return rows === 1;
}
private static computeNextRunStartAt(
knex: Knex,
settings: TaskSettingsV2,
): Knex.Raw {
const isManual = settings?.cadence === 'manual';
const isDuration = settings?.cadence.startsWith('P');
const isCron = !isManual && !isDuration;
if (isCron) {
const time = new CronTime(settings.cadence).sendAt().toUTC();
const timeConverted = DateTime.fromJSDate(time.toJSDate());
return TaskWorker.nextRunAtRaw(knex, timeConverted);
}
if (isManual) {
return knex.raw('null');
}
const dt = Duration.fromISO(settings.cadence).as('seconds');
if (knex.client.config.client.includes('sqlite3')) {
return knex.raw(`max(datetime(next_run_start_at, ?), datetime('now'))`, [
`+${dt} seconds`,
]);
}
if (knex.client.config.client.includes('mysql')) {
return knex.raw(
`greatest(next_run_start_at + interval ${dt} second, now())`,
);
}
return knex.raw(
`greatest(next_run_start_at + interval '${dt} seconds', now())`,
);
}
async tryReleaseTask(
ticket: string,
settings: TaskSettingsV2,
error?: Error,
): Promise<boolean> {
const isManual = settings?.cadence === 'manual';
const isDuration = settings?.cadence.startsWith('P');
const isCron = !isManual && !isDuration;
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}`);
// We make a conversion here to make typescript happy, because the luxon versions of the cron library and here may not be the same
const timeConverted = DateTime.fromJSDate(time.toJSDate());
nextRun = this.nextRunAtRaw(timeConverted);
} else if (isManual) {
nextRun = this.knex.raw('null');
} 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 nextRun = TaskWorker.computeNextRunStartAt(this.knex, settings);
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
@@ -467,12 +537,13 @@ export class TaskWorker {
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 })]);
private static nextRunAtRaw(knex: Knex, time: DateTime): Knex.Raw {
if (knex.client.config.client.includes('sqlite3')) {
return knex.raw('datetime(?)', [time.toISO()]);
}
return this.knex.raw(`?`, [time.toISO()]);
if (knex.client.config.client.includes('mysql')) {
return knex.raw(`?`, [time.toSQL({ includeOffset: false })]);
}
return knex.raw(`?`, [time.toISO()]);
}
}
@@ -18,6 +18,7 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';
import { DefaultSchedulerService } from './lib/DefaultSchedulerService';
/**
@@ -37,6 +38,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle: coreServices.rootLifecycle,
httpRouter: coreServices.httpRouter,
pluginMetadata: coreServices.pluginMetadata,
metrics: metricsServiceRef,
},
async factory({
database,
@@ -44,6 +46,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
}) {
return DefaultSchedulerService.create({
database,
@@ -51,6 +54,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
});
},
});
@@ -1,656 +0,0 @@
/*
* 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 {
BitbucketIntegration,
readBitbucketIntegrationConfig,
} from '@backstage/integration';
import {
createMockDirectory,
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'node:path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
import getRawBody from 'raw-body';
import { UrlReaderServiceReadUrlResponse } from '@backstage/backend-plugin-api';
const logger = mockServices.logger.mock();
describe('BitbucketUrlReader.factory', () => {
it('only apply integration configs not inherited from bitbucketCloud or bitbucketServer', () => {
const config = new ConfigReader({
integrations: {
bitbucket: [],
bitbucketCloud: [
{
username: 'username',
appPassword: 'password',
},
],
bitbucketServer: [
{
host: 'bitbucket-server.local',
token: 'test-token',
},
],
},
});
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: config,
});
const tuples = BitbucketUrlReader.factory({
config,
logger,
treeResponseFactory,
});
expect(tuples).toHaveLength(0);
});
});
describe('BitbucketUrlReader', () => {
const mockDir = createMockDirectory({ mockOsTmpDir: true });
beforeEach(mockDir.clear);
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const bitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const worker = setupServer();
registerMswTestHooks(worker);
describe('readUrl', () => {
it('should be able to readUrl via buffer without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl using provided token', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer manual-token',
);
return res(ctx.status(200), ctx.body('foo'));
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ token: 'manual-token' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl via stream without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const fromStream = await getRawBody(result.stream!());
expect(fromStream.toString()).toBe('foo');
});
it('should be able to readUrl with matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'matching-etag-value',
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'matching-etag-value' },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl without matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'previous-etag-value',
);
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'new-etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'previous-etag-value' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.etag).toBe('new-etag-value');
});
it('should be able to readUrl via buffer without If-Modified-Since', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
expect(buffer.toString()).toBe('foo');
});
it('should be throw not modified when If-Modified-Since returns a 304', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl when If-Modified-Since is before Last-Modified', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(
ctx.status(200),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
ctx.body('foo'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
});
});
describe('read', () => {
it('rejects unknown targets', async () => {
await expect(
bitbucketProcessor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
});
});
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(2);
const mkDocsFile = await files[0].content();
const indexMarkdownFile = await files[1].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
});
it('creates a directory with the wanted files', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('uses private bitbucket host', async () => {
const response = await hostedBitbucketProcessor.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('returns the wanted files from an archive with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('creates a directory with the wanted files with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: '12ab34cd56ef' },
);
};
await expect(fnBitbucket).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: 'outdatedetag123abc' },
);
expect(response.etag).toBe('12ab34cd56ef');
});
});
describe('search hosted', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/docs/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
describe('search private', () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
it('should work for exact URLs', async () => {
hostedBitbucketProcessor.readUrl = jest.fn().mockResolvedValue({
buffer: async () => Buffer.from('content'),
etag: 'etag',
} as UrlReaderServiceReadUrlResponse);
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect(result.etag).toBe('etag');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect((await result.files[0].content()).toString()).toEqual('content');
});
});
});
@@ -1,313 +0,0 @@
/*
* 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 {
UrlReaderService,
UrlReaderServiceReadTreeOptions,
UrlReaderServiceReadTreeResponse,
UrlReaderServiceReadUrlOptions,
UrlReaderServiceReadUrlResponse,
UrlReaderServiceSearchOptions,
UrlReaderServiceSearchResponse,
} from '@backstage/backend-plugin-api';
import {
assertError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import {
BitbucketIntegration,
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ReaderFactory, ReadTreeResponseFactory } from './types';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket v1 and v2 APIs, such
* as the one exposed by Bitbucket Cloud itself.
*
* @public
* @deprecated in favor of BitbucketCloudUrlReader and BitbucketServerUrlReader
*/
export class BitbucketUrlReader implements UrlReaderService {
static factory: ReaderFactory = ({ config, logger, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucket
.list()
.filter(
item =>
!integrations.bitbucketCloud.byHost(item.config.host) &&
!integrations.bitbucketServer.byHost(item.config.host),
)
.map(integration => {
const reader = new BitbucketUrlReader(integration, logger, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
private readonly integration: BitbucketIntegration;
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory };
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.integration = integration;
this.deps = deps;
const { host, token, username, appPassword } = integration.config;
const replacement =
host === 'bitbucket.org' ? 'bitbucketCloud' : 'bitbucketServer';
logger.warn(
`[Deprecated] Please migrate from "integrations.bitbucket" to "integrations.${replacement}".`,
);
if (!token && username && !appPassword) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
);
}
}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
private getCredentials = async (options?: {
token?: string;
}): Promise<{ headers: Record<string, string> }> => {
if (options?.token) {
return {
headers: {
Authorization: `Bearer ${options.token}`,
},
};
}
return await getBitbucketRequestOptions(this.integration.config);
};
async readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse> {
const { etag, lastModifiedAfter, signal } = options ?? {};
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config);
const requestOptions = await this.getCredentials(options);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), {
headers: {
...requestOptions.headers,
...(etag && { 'If-None-Match': etag }),
...(lastModifiedAfter && {
'If-Modified-Since': lastModifiedAfter.toUTCString(),
}),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
// removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return ReadUrlResponseFactory.fromResponse(response);
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
async readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse> {
const { filepath } = parseGitUrl(url);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketDownloadUrl(
url,
this.integration.config,
);
const archiveBitbucketResponse = await fetch(
downloadUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!archiveBitbucketResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`;
if (archiveBitbucketResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
response: archiveBitbucketResponse,
subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
async search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse> {
const { filepath } = parseGitUrl(url);
// If it's a direct URL we use readUrl instead
if (!filepath?.match(/[*?]/)) {
try {
const data = await this.readUrl(url, options);
return {
files: [
{
url: url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt,
},
],
etag: data.etag ?? '',
};
} catch (error) {
assertError(error);
if (error.name === 'NotFoundError') {
return {
files: [],
etag: '',
};
}
throw error;
}
}
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = trimEnd(url.replace(filepath, ''), '/');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(path),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
lastModifiedAt: file.lastModifiedAt,
})),
};
}
toString() {
const { host, token, username, appPassword } = this.integration.config;
let authed = Boolean(token);
if (!authed) {
authed = Boolean(username && appPassword);
}
return `bitbucket{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<string> {
const { resource, name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, this.integration.config);
}
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
const commitsApiUrl = isHosted
? `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
: `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
if (commitsResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commits = await commitsResponse.json();
if (isHosted) {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].hash
) {
return commits.values[0].hash.substring(0, 12);
}
} else {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].id
) {
return commits.values[0].id.substring(0, 12);
}
}
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
@@ -78,9 +78,6 @@ describe('GitlabUrlReader', () => {
describe('read', () => {
beforeEach(() => {
worker.use(
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get('*', (req, res, ctx) =>
res(
ctx.status(200),
@@ -107,14 +104,14 @@ describe('GitlabUrlReader', () => {
url: 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
config: createConfig(),
response: expect.objectContaining({
url: 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
url: 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FteamA%2FsubgroupA%2FrepoA/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
}),
},
{
url: 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
config: createConfig('0123456789'),
response: expect.objectContaining({
url: 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
url: 'https://gitlab.example.com/api/v4/projects/groupA%2Fteams%2FteamA%2FsubgroupA%2FrepoA/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
headers: expect.objectContaining({
authorization: 'Bearer 0123456789',
}),
@@ -124,7 +121,7 @@ describe('GitlabUrlReader', () => {
url: 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup
config: createConfig(),
response: expect.objectContaining({
url: 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
url: 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FteamA%2FrepoA/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
}),
},
@@ -133,7 +130,7 @@ describe('GitlabUrlReader', () => {
url: 'https://gitlab.example.com/a/b/blob/master/c.yaml',
config: createConfig(),
response: expect.objectContaining({
url: 'https://gitlab.example.com/api/v4/projects/12345/repository/files/c.yaml/raw?ref=master',
url: 'https://gitlab.example.com/api/v4/projects/a%2Fb/repository/files/c.yaml/raw?ref=master',
}),
},
])('should handle happy path %#', async ({ url, config, response }) => {
@@ -177,9 +174,6 @@ describe('GitlabUrlReader', () => {
it('should throw NotModified on HTTP 304 from etag', async () => {
worker.use(
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get('*', (req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe('999');
return res(ctx.status(304));
@@ -198,9 +192,6 @@ describe('GitlabUrlReader', () => {
it('should throw NotModified on HTTP 304 from lastModifiedAt', async () => {
worker.use(
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get('*', (req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('2019 12 31 23:59:59 GMT').toUTCString(),
@@ -221,9 +212,6 @@ describe('GitlabUrlReader', () => {
it('should return etag and last-modified in response', async () => {
worker.use(
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get('*', (_req, res, ctx) => {
return res(
ctx.status(200),
@@ -248,15 +236,6 @@ describe('GitlabUrlReader', () => {
it('should return the file when using a user token', async () => {
worker.use(
rest.get('*/api/v4/projects/user%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-user-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
rest.get('*', (_req, res, ctx) => {
return res(ctx.status(200), ctx.body('foo'));
}),
@@ -566,18 +545,6 @@ describe('GitlabUrlReader', () => {
});
it('should return the file when using a user token', async () => {
worker.use(
rest.get('*/api/v4/projects/user%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-user-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
);
const response = await gitlabProcessor.readTree(
'https://gitlab.com/user/project/tree/main',
{ token: 'gl-user-token' },
@@ -738,30 +705,13 @@ describe('GitlabUrlReader', () => {
});
describe('getGitlabFetchUrl', () => {
beforeEach(() => {
worker.use(
rest.get(
'*/api/v4/projects/group%2Fsubgroup%2Fproject',
(_, res, ctx) => res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get('*/api/v4/projects/user%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-user-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
);
});
it('should fall back to getGitLabFileFetchUrl for blob urls', async () => {
await expect(
(gitlabProcessor as any).getGitlabFetchUrl(
'https://gitlab.com/group/subgroup/project/-/blob/branch/my/path/to/file.yaml',
),
).resolves.toEqual(
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
);
});
it('should work for job artifact urls', async () => {
@@ -770,7 +720,7 @@ describe('GitlabUrlReader', () => {
'https://gitlab.com/group/subgroup/project/-/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
),
).resolves.toEqual(
'https://gitlab.com/api/v4/projects/12345/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
'https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
);
});
it('should fail on unfamiliar or non-Gitlab urls', async () => {
@@ -779,7 +729,7 @@ describe('GitlabUrlReader', () => {
'https://gitlab.com/some/random/endpoint',
),
).rejects.toThrow(
'Failed converting /some/random/endpoint to a project id. Url path must include /blob/.',
'Failed extracting project path from /some/random/endpoint. Url path must include /blob/.',
);
});
it('should resolve the project path using a user token', async () => {
@@ -789,39 +739,18 @@ describe('GitlabUrlReader', () => {
'gl-user-token',
),
).resolves.toEqual(
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/user%2Fproject/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
);
});
});
describe('getGitlabArtifactFetchUrl', () => {
beforeEach(() => {
worker.use(
rest.get(
'*/api/v4/projects/group%2Fsubgroup%2Fproject',
(_, res, ctx) => res(ctx.status(200), ctx.json({ id: 12345 })),
),
rest.get(
'*/api/v4/projects/groupA%2Fsubgroup%2Fproject',
(_, res, ctx) => res(ctx.status(404)),
),
rest.get('*/api/v4/projects/user%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-user-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
);
});
it('should reject urls that are not for the job artifacts API', async () => {
await expect(
it('should reject urls that are not for the job artifacts API', () => {
expect(() =>
(gitlabProcessor as any).getGitlabArtifactFetchUrl(
new URL('https://gitlab.com/some/url'),
),
).rejects.toThrow('Unable to process url as an GitLab artifact');
).toThrow('Unable to process url as an GitLab artifact');
});
it('should work for job artifact urls', async () => {
await expect(
@@ -832,18 +761,22 @@ describe('GitlabUrlReader', () => {
),
).resolves.toEqual(
new URL(
'https://gitlab.com/api/v4/projects/12345/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
'https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
),
);
});
it('errors in mapping the project ID should be captured', async () => {
it('should work for job artifact urls with any project path', async () => {
await expect(
(gitlabProcessor as any).getGitlabArtifactFetchUrl(
new URL(
'https://gitlab.com/groupA/subgroup/project/-/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
),
),
).rejects.toThrow(/^Unable to translate GitLab artifact URL:/);
).resolves.toEqual(
new URL(
'https://gitlab.com/api/v4/projects/groupA%2Fsubgroup%2Fproject/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
),
);
});
it('should resolve the project path using a user token', async () => {
await expect(
@@ -855,51 +788,9 @@ describe('GitlabUrlReader', () => {
),
).resolves.toEqual(
new URL(
'https://gitlab.com/api/v4/projects/12345/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
'https://gitlab.com/api/v4/projects/user%2Fproject/jobs/artifacts/branch/raw/my/path/to/file.yaml?job=myJob',
),
);
});
});
describe('resolveProjectToId', () => {
beforeEach(() => {
worker.use(
rest.get('*/api/v4/projects/group%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-dummy-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
rest.get('*/api/v4/projects/user%2Fproject', (req, res, ctx) => {
if (req.headers.get('authorization') !== 'Bearer gl-user-token') {
return res(
ctx.status(401),
ctx.json({ message: '401 Unauthorized' }),
);
}
return res(ctx.status(200), ctx.json({ id: 12345 }));
}),
);
});
it('should resolve the project path to a valid project id', async () => {
await expect(
(gitlabProcessor as any).resolveProjectToId(
new URL('https://gitlab.com/group/project'),
),
).resolves.toEqual(12345);
});
it('should resolve the project path to a valid project id using a user token', async () => {
await expect(
(gitlabProcessor as any).resolveProjectToId(
new URL('https://gitlab.com/user/project'),
'gl-user-token',
),
).resolves.toEqual(12345);
});
});
});
@@ -14,9 +14,6 @@
* limitations under the License.
*/
// NOTE(freben): Intentionally uses node-fetch because of https://github.com/backstage/backstage/issues/28190
import fetch, { Response } from 'node-fetch';
import {
UrlReaderService,
UrlReaderServiceReadTreeOptions,
@@ -41,10 +38,8 @@ import {
import parseGitUrl from 'git-url-parse';
import { trimEnd, trimStart } from 'lodash';
import { Minimatch } from 'minimatch';
import { Readable } from 'node:stream';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
import { ReaderFactory, ReadTreeResponseFactory } from './types';
import { parseLastModified } from './util';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files on GitLab.
@@ -89,7 +84,7 @@ export class GitlabUrlReader implements UrlReaderService {
let response: Response;
try {
response = await fetch(builtUrl, {
response = await this.integration.fetch(builtUrl, {
headers: {
...getGitLabRequestOptions(this.integration.config, token).headers,
...(etag && !isArtifact && { 'If-None-Match': etag }),
@@ -115,12 +110,7 @@ export class GitlabUrlReader implements UrlReaderService {
}
if (response.ok) {
return ReadUrlResponseFactory.fromNodeJSReadable(response.body, {
etag: response.headers.get('ETag') ?? undefined,
lastModifiedAt: parseLastModified(
response.headers.get('Last-Modified'),
),
});
return ReadUrlResponseFactory.fromResponse(response);
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
@@ -155,7 +145,7 @@ export class GitlabUrlReader implements UrlReaderService {
// Use GitLab API to get the default branch
// encodeURIComponent is required for GitLab API
// https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding
const projectGitlabResponse = await fetch(
const projectGitlabResponse = await this.integration.fetch(
new URL(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName,
@@ -182,7 +172,7 @@ export class GitlabUrlReader implements UrlReaderService {
if (!!filepath) {
commitsReqParams.set('path', filepath);
}
const commitsGitlabResponse = await fetch(
const commitsGitlabResponse = await this.integration.fetch(
new URL(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName,
@@ -218,23 +208,23 @@ export class GitlabUrlReader implements UrlReaderService {
archiveReqParams.set('path', filepath);
}
// https://docs.gitlab.com/ee/api/repositories.html#get-file-archive
const archiveGitLabResponse = await fetch(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName,
)}/repository/archive?${archiveReqParams.toString()}`,
{
...getGitLabRequestOptions(this.integration.config, token),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
},
);
const reqUrl = `${
this.integration.config.apiBaseUrl
}/projects/${encodeURIComponent(
repoFullName,
)}/repository/archive?${archiveReqParams.toString()}`;
const archiveGitLabResponse = await this.integration.fetch(reqUrl, {
...getGitLabRequestOptions(this.integration.config, token),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
});
if (!archiveGitLabResponse.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
const message = `Failed to read tree (archive) from ${url}, ${reqUrl}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
if (archiveGitLabResponse.status === 404) {
throw new NotFoundError(message);
}
@@ -242,7 +232,7 @@ export class GitlabUrlReader implements UrlReaderService {
}
return await this.deps.treeResponseFactory.fromTarArchive({
stream: Readable.from(archiveGitLabResponse.body),
response: archiveGitLabResponse,
subpath: filepath,
etag: commitSha,
filter: options?.filter,
@@ -337,74 +327,48 @@ export class GitlabUrlReader implements UrlReaderService {
// If the target is for a job artifact then go down that path
const targetUrl = new URL(target);
if (targetUrl.pathname.includes('/-/jobs/artifacts/')) {
return this.getGitlabArtifactFetchUrl(targetUrl, token).then(value =>
return this.getGitlabArtifactFetchUrl(targetUrl).then(value =>
value.toString(),
);
}
// Default to the old behavior of assuming the url is for a file
// Default to the optimized behavior - no API call needed for file URLs
return getGitLabFileFetchUrl(target, this.integration.config, token);
}
// convert urls of the form:
// https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
// to urls of the form:
// https://example.com/api/v4/projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=<job_name>
private async getGitlabArtifactFetchUrl(
target: URL,
token?: string,
): Promise<URL> {
// https://example.com/api/v4/projects/namespace%2Fproject/jobs/artifacts/:ref_name/raw/*artifact_path?job=<job_name>
private getGitlabArtifactFetchUrl(target: URL): Promise<URL> {
if (!target.pathname.includes('/-/jobs/artifacts/')) {
throw new Error('Unable to process url as an GitLab artifact');
}
try {
const [namespaceAndProject, ref] =
target.pathname.split('/-/jobs/artifacts/');
const projectPath = new URL(target);
projectPath.pathname = namespaceAndProject;
const projectId = await this.resolveProjectToId(projectPath, token);
// Extract project path directly instead of making API call
const relativePath = getGitLabIntegrationRelativePath(
this.integration.config,
);
let projectPath = namespaceAndProject;
// Check relative path exist and remove it
if (relativePath) {
projectPath = trimStart(projectPath, relativePath);
}
// Trim an initial / if it exists
projectPath = projectPath.replace(/^\//, '');
const newUrl = new URL(target);
newUrl.pathname = `${relativePath}/api/v4/projects/${projectId}/jobs/artifacts/${ref}`;
return newUrl;
newUrl.pathname = `${relativePath}/api/v4/projects/${encodeURIComponent(
projectPath,
)}/jobs/artifacts/${ref}`;
return Promise.resolve(newUrl);
} catch (e) {
throw new Error(
`Unable to translate GitLab artifact URL: ${target}, ${e}`,
);
}
}
private async resolveProjectToId(
pathToProject: URL,
token?: string,
): Promise<number> {
let project = pathToProject.pathname;
// Check relative path exist and remove it if so
const relativePath = getGitLabIntegrationRelativePath(
this.integration.config,
);
if (relativePath) {
project = project.replace(relativePath, '');
}
// Trim an initial / if it exists
project = project.replace(/^\//, '');
const result = await fetch(
`${
pathToProject.origin
}${relativePath}/api/v4/projects/${encodeURIComponent(project)}`,
getGitLabRequestOptions(this.integration.config, token),
);
const data = await result.json();
if (!result.ok) {
if (result.status === 401) {
throw new Error(
'GitLab Error: 401 - Unauthorized. The access token used is either expired, or does not have permission to read the project',
);
}
throw new Error(`Gitlab error: ${data.error}, ${data.error_description}`);
}
return Number(data.id);
}
}
@@ -24,7 +24,6 @@ import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GerritUrlReader } from './GerritUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
@@ -92,7 +91,6 @@ export class UrlReaders {
AzureUrlReader.factory,
BitbucketCloudUrlReader.factory,
BitbucketServerUrlReader.factory,
BitbucketUrlReader.factory,
GerritUrlReader.factory,
GithubUrlReader.factory,
GiteaUrlReader.factory,
@@ -16,7 +16,6 @@
export { AzureUrlReader } from './AzureUrlReader';
export { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
export { BitbucketUrlReader } from './BitbucketUrlReader';
export { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
export { GerritUrlReader } from './GerritUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
+6
View File
@@ -1,5 +1,11 @@
# @backstage/backend-dev-utils
## 0.1.7
### Patch Changes
- 7455dae: Use node prefix on native imports
## 0.1.7-next.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-dev-utils",
"version": "0.1.7-next.0",
"version": "0.1.7",
"backstage": {
"role": "node-library"
},
@@ -1,5 +1,103 @@
# @backstage/backend-dynamic-feature-service
## 0.8.0-next.2
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
- @backstage/backend-defaults@0.16.0-next.2
- @backstage/cli-common@0.2.0-next.2
- @backstage/plugin-catalog-backend@3.5.0-next.2
- @backstage/backend-openapi-utils@0.6.7-next.1
- @backstage/plugin-app-node@0.1.43-next.1
- @backstage/plugin-auth-node@0.6.14-next.2
- @backstage/plugin-events-backend@0.6.0-next.2
- @backstage/plugin-events-node@0.4.20-next.1
- @backstage/plugin-permission-node@0.10.11-next.1
- @backstage/plugin-scaffolder-node@0.13.0-next.2
- @backstage/plugin-search-backend-node@1.4.2-next.1
## 0.8.0-next.1
### Minor Changes
- 0fbcf23: Migrated OpenAPI schemas to 3.1.
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-backend@3.5.0-next.1
- @backstage/cli-common@0.2.0-next.1
- @backstage/cli-node@0.2.19-next.1
- @backstage/backend-defaults@0.16.0-next.1
- @backstage/plugin-events-backend@0.6.0-next.1
- @backstage/plugin-scaffolder-node@0.13.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/config-loader@1.10.9-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-app-node@0.1.43-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-search-common@1.2.22
## 0.7.10-next.0
### Patch Changes
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/cli-node@0.2.19-next.0
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/plugin-catalog-backend@3.5.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-app-node@0.1.43-next.0
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-backend@0.5.12-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-scaffolder-node@0.12.6-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-search-common@1.2.22
## 0.7.9
### Patch Changes
- 7455dae: Use node prefix on native imports
- fdbd404: Updated `@module-federation/enhanced`, `@module-federation/runtime`, and `@module-federation/sdk` dependencies from `^0.9.0` to `^0.21.6`.
- 9b4c414: Updated README for backend-dynamic-feature-service
- Updated dependencies
- @backstage/plugin-catalog-backend@3.4.0
- @backstage/backend-openapi-utils@0.6.6
- @backstage/backend-plugin-api@1.7.0
- @backstage/plugin-search-backend-node@1.4.1
- @backstage/backend-defaults@0.15.2
- @backstage/plugin-scaffolder-node@0.12.5
- @backstage/config-loader@1.10.8
- @backstage/plugin-events-backend@0.5.11
- @backstage/plugin-search-common@1.2.22
- @backstage/cli-common@0.1.18
- @backstage/cli-node@0.2.18
- @backstage/plugin-auth-node@0.6.13
- @backstage/plugin-app-node@0.1.42
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.10
- @backstage/plugin-events-node@0.4.19
## 0.7.9-next.1
### Patch Changes
@@ -97,7 +97,7 @@ Since this service only handles loading, you would choose a packaging approach b
**When to use:** Plugin only uses dependencies that are already provided by the main Backstage application.
**How to apply:**
**How to use:**
```bash
cd my-backstage-plugin
@@ -117,7 +117,7 @@ tar -xzf package.tgz -C /path/to/dynamic-plugins-root/my-backstage-plugin --stri
**When to use:** Plugin has private dependencies not available in the main Backstage application.
**How to apply:**
**How to use:**
```bash
# Package the plugin
@@ -136,35 +136,47 @@ yarn install # Installs all the plugin's dependencies
**Example scenario:** Plugin needs `axios@1.4.0` which isn't available in the main application.
### 3. Custom packaging CLI tool
### 3. Dedicated bundling CLI command
**When to use:** When you want to produce self-contained dynamic plugin packages that can be directly extracted without any post-action, and systematically use the core `@backstage` dependencies provided by the Backstage application.
**When to use:**
**What a packaging CLI needs to do:**
- When you want to produce self-contained dynamic plugin packages that can be directly extracted and loaded without any post-action,
- especially when your plugin depends on other packages in the same monorepo.
1. **Analyze plugin dependencies** - Identify which are Backstage core vs private dependencies
2. **Create distribution package** - Generate a new directory with modified structure:
- Move `@backstage/*` packages from `dependencies` to `peerDependencies` in package.json
- Keep only private dependencies in the `dependencies` section
- Keep the built JavaScript code unchanged
- Include only the filtered private dependencies in `node_modules`
3. **Result** - A self-contained package that uses the main app's `@backstage/*` packages but includes its own private dependencies
**How to use:**
**Benefits:**
- Systematic use of main application's `@backstage/*` packages (no version conflicts), enabling the future implementation of `@backstage` dependency version checking at start time
- Self-contained packages with only necessary private dependencies
- No post-installation steps required (extract and run)
- Consistent dependency structure across all dynamic plugins
- Production-ready distribution format
**Example implementation:** The [`@red-hat-developer-hub/cli`](https://github.com/redhat-developer/rhdh-cli) tool implements this approach:
The [`backstage-cli package bundle`](../cli/cli-report.md) command automates the required steps. Run it from within a plugin directory:
```bash
cd my-backstage-plugin
npx @red-hat-developer-hub/cli@latest plugin export
# Creates a self-contained package with embedded dependencies in the `/dist-dynamic` sub-folder
# Deploy the generated package
cp -r dist-dynamic /path/to/dynamic-plugins-root/my-backstage-plugin
yarn backstage-cli package bundle --output-destination /path/to/dynamic-plugins-root
# Creates a self-contained bundle in the /path/to/dynamic-plugins-root/my-backstage-plugin/ sub-folder
```
**Batch bundling:** When bundling many plugins from the same monorepo, use `--pre-packed-dir` to avoid redundant work:
```bash
# First, build a shared dist workspace
backstage-cli build-workspace dist-workspace --alwaysPack ...plugin-packages
# Then bundle each plugin using the pre-packed output
cd plugins/my-backstage-plugin
backstage-cli package bundle --pre-packed-dir ../../dist-workspace
```
See the full list of options in the [CLI reference](../cli/cli-report.md).
**What the command does:**
1. **Builds the plugin** — Produces CJS output for the backend plugin and its transitively-required monorepo packages (`*-node` or `*-common`)
2. **Packs local packages** — Resolves `workspace:^` and `backstage:^` dependencies on both the main plugin package and its transitively-required monorepo packages (`*-node` or `*-common`)
3. **Installs private dependencies** — Seeds a lockfile from the plugin source monorepo and prunes it, then installs a private `node_modules` containing all required dependencies
4. **Collects configuration schemas** — Gathers plugin config schemas and writes them to `dist/.config-schema.json` so they are available for validation at load time
**Benefits:**
- Self-contained packages with all necessary dependencies
- No post-installation steps required (extract and run)
- Consistent dependency structure across all dynamic plugins
- Automatic configuration schema collection
- Production-ready distribution format
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-dynamic-feature-service",
"version": "0.7.9-next.1",
"version": "0.8.0-next.2",
"description": "Backstage dynamic feature service",
"backstage": {
"role": "node-library"
@@ -0,0 +1,28 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
// This require resolves to the bundled proxy copy inside this plugin's
// own node_modules/@backstage/backend-plugin-api, NOT the host's copy.
var backendPluginApi = require('@backstage/backend-plugin-api');
// Triggers the _resolveFilename fallback: require.resolve runs from
// the bundled @backstage/backend-plugin-api whose mod.path is inside
// this plugin's node_modules.
var pkgDir = backendPluginApi.resolvePackagePath('plugin-test-backend-bundled');
const testBundledPlugin = backendPluginApi.createBackendPlugin({
pluginId: "test-bundled",
register(env) {
env.registerInit({
deps: {
logger: backendPluginApi.coreServices.rootLogger,
},
async init({ logger }) {
logger.info("Bundled backend plugin loaded successfully");
}
});
}
});
exports.default = testBundledPlugin;
@@ -0,0 +1,22 @@
'use strict';
// Proxy that simulates a bundled copy of @backstage/backend-plugin-api.
// Re-exports everything from the real workspace package, but provides a
// local resolvePackagePath so that require.resolve() runs from THIS
// file's context (mod.path inside node_modules/@backstage/backend-plugin-api).
const nodePath = require('node:path');
const real = require('../../../../../../../../../backend-plugin-api/src');
function resolvePackagePath(name) {
const args = Array.prototype.slice.call(arguments, 1);
const pkgJson = require.resolve(name + '/package.json');
return nodePath.resolve.apply(
nodePath,
[nodePath.dirname(pkgJson)].concat(args),
);
}
module.exports = Object.assign({}, real, {
resolvePackagePath: resolvePackagePath,
});
@@ -0,0 +1,6 @@
{
"name": "@backstage/backend-plugin-api",
"version": "0.0.0",
"description": "Proxy that re-exports the real @backstage/backend-plugin-api with a local resolvePackagePath, simulating a bundled copy.",
"main": "index.js"
}
@@ -0,0 +1,31 @@
{
"name": "plugin-test-backend-bundled-dynamic",
"version": "0.0.0",
"description": "A test dynamic backend plugin that bundles its own @backstage/backend-plugin-api.",
"backstage": {
"role": "backend-plugin",
"pluginId": "test-bundled",
"pluginPackages": [
"plugin-test-backend-bundled"
]
},
"keywords": [
"backstage",
"dynamic"
],
"exports": {
".": {
"require": "./dist/index.cjs.js",
"default": "./dist/index.cjs.js"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs.js",
"files": [
"dist"
],
"dependencies": {
"@backstage/backend-plugin-api": "0.0.0"
},
"bundleDependencies": true
}
@@ -465,6 +465,45 @@ Require stack:
});
});
it('should load a backend plugin that bundles its own @backstage/backend-plugin-api', async () => {
const dynamicPluginsLister = new DynamicPluginLister();
const dynamicPluginsRootForBundled = resolvePath(
__dirname,
'__fixtures__/dynamic-plugins-root-for-bundled',
);
await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
dynamicPlugins: {
rootDirectory: dynamicPluginsRootForBundled,
},
backend: {
baseUrl: `http://localhost:0`,
},
},
}),
dynamicPluginsFeatureLoader({
moduleLoader: logger =>
jestFreeTypescriptAwareModuleLoader({ logger }),
}),
dynamicPluginsLister.feature(),
],
});
expect(dynamicPluginsLister.loadedPlugins).toMatchObject([
{
installer: {
kind: 'new',
},
name: 'plugin-test-backend-bundled-dynamic',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
]);
});
describe('module federation support', () => {
const createRemoteProviderPlugin = (
provider: FrontendRemoteResolverProvider,
@@ -99,15 +99,21 @@ export class CommonJSModuleLoader implements ModuleLoader {
);
}
// Are we trying to resolve a `package.json` from an originating module of the core backstage application
// (this is mostly done by calling `@backstage/backend-plugin-api/resolvePackagePath`).
const resolvingPackageJsonFromBackstageApplication =
// Is this a `resolvePackagePath` call from `@backstage/backend-plugin-api`?
// This covers both the host application's copy and a bundled copy living
// inside a dynamic plugin's own node_modules.
// The regex matches mod.path against the various ways the package can be resolved on disk
// (with optional subdirectory such as /src or /dist after the package name):
// - .../node_modules/@backstage/backend-plugin-api[/...] (npm-installed)
// - .../<plugin>/node_modules/@backstage/backend-plugin-api[/...] (bundled)
// - .../packages/backend-plugin-api[/...] (symlinked workspace in monorepo)
const resolvingPackageJsonViaResolvePackagePath =
request?.endsWith('/package.json') &&
mod?.path &&
!dynamicPluginsPaths.some(p => mod.path.startsWith(p));
/[/\\](?:@backstage|packages)[/\\]backend-plugin-api(?:[/\\]|$)/.test(
mod?.path ?? '',
);
// If not, we don't need the dedicated specific case below.
if (!resolvingPackageJsonFromBackstageApplication) {
if (!resolvingPackageJsonViaResolvePackagePath) {
throw errorToThrow;
}
@@ -38,7 +38,7 @@ import { createSpecializedBackend } from '@backstage/backend-app-api';
import { ConfigSources } from '@backstage/config-loader';
import { Logs, MockedLogger, LogContent } from '../__testUtils__/testUtils';
import { PluginScanner } from '../scanner/plugin-scanner';
import { findPaths } from '@backstage/cli-common';
import { targetPaths } from '@backstage/cli-common';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { BackstagePackageJson, PackageRole } from '@backstage/cli-node';
@@ -956,7 +956,7 @@ describe('backend-dynamic-feature-service', () => {
mockDir.setContent({
'package.json': fs.readFileSync(
findPaths(__dirname).resolveTargetRoot('package.json'),
targetPaths.resolveRoot('package.json'),
),
'dynamic-plugins-root': {},
'dynamic-plugins-root/a-dynamic-plugin': ctx =>
@@ -1044,7 +1044,7 @@ describe('backend-dynamic-feature-service', () => {
otherMockDir.resolve('a-dynamic-plugin'),
);
expect(mockedModuleLoader.bootstrap).toHaveBeenCalledWith(
findPaths(__dirname).targetRoot,
targetPaths.rootDir,
[realPath],
new Map<string, ScannedPluginManifest>([
[
@@ -35,7 +35,7 @@ import {
createServiceRef,
} from '@backstage/backend-plugin-api';
import { PackageRole, PackageRoles } from '@backstage/cli-node';
import { findPaths } from '@backstage/cli-common';
import { targetPaths } from '@backstage/cli-common';
import * as fs from 'node:fs';
/**
@@ -56,7 +56,7 @@ export class DynamicPluginManager implements DynamicPluginProvider {
options: DynamicPluginManagerOptions,
): Promise<DynamicPluginManager> {
/* eslint-disable-next-line no-restricted-syntax */
const backstageRoot = findPaths(__dirname).targetRoot;
const backstageRoot = targetPaths.rootDir;
const scanner = PluginScanner.create({
config: options.config,
logger: options.logger,
@@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.1.0
info:
title: .backstage/dynamic-features
version: '1'
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -26,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -21,7 +21,7 @@ import { createValidatedOpenApiRouterFromGeneratedEndpointMap } from '@backstage
import { EndpointMap } from './apis';
export const spec = {
openapi: '3.0.3',
openapi: '3.1.0',
info: {
title: '.backstage/dynamic-features',
version: '1',
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -20,7 +20,7 @@ import {
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { findPaths } from '@backstage/cli-common';
import { targetPaths } from '@backstage/cli-common';
import fs from 'fs-extra';
import * as path from 'node:path';
@@ -100,7 +100,7 @@ const dynamicPluginsSchemasServiceFactoryWithOptions = (
config,
logger,
// eslint-disable-next-line no-restricted-syntax
backstageRoot: findPaths(__dirname).targetRoot,
backstageRoot: targetPaths.rootDir,
preferAlpha: true,
});
@@ -1,5 +1,29 @@
# @backstage/backend-openapi-utils
## 0.6.7-next.1
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
## 0.6.7-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
## 0.6.6
### Patch Changes
- 7455dae: Use node prefix on native imports
- Updated dependencies
- @backstage/backend-plugin-api@1.7.0
## 0.6.6-next.0
### Patch Changes
+2
View File
@@ -4,6 +4,8 @@
This package is meant to provide a typed Express router for an OpenAPI spec. Based on the [`oatx`](https://github.com/varanauskas/oatx) library and adapted to override Express values.
Only supports OpenAPI 3.1 specifications.
## Getting Started
### Configuration
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-openapi-utils",
"version": "0.6.6-next.0",
"version": "0.6.7-next.1",
"description": "OpenAPI typescript support.",
"backstage": {
"role": "node-library"
@@ -54,6 +54,7 @@ export function getOpenApiSpecRoute(baseUrl: string) {
/**
* Create a router with validation middleware. This is used by typing methods to create an
* "OpenAPI router" with all of the expected validation + metadata.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -115,6 +116,7 @@ function createRouterWithValidation(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -132,6 +134,7 @@ export function createValidatedOpenApiRouter<T extends RequiredDoc>(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
+46
View File
@@ -1,5 +1,51 @@
# @backstage/backend-plugin-api
## 1.8.0-next.1
### Minor Changes
- 015668c: Added `cancelTask` method to the `SchedulerService` interface and implementation, allowing cancellation of currently running scheduled tasks. For global tasks, the database lock is released and a periodic liveness check aborts the running task function. For local tasks, the task's abort signal is triggered directly. A new `POST /.backstage/scheduler/v1/tasks/:id/cancel` endpoint is also available.
### Patch Changes
- Updated dependencies
- @backstage/cli-common@0.2.0-next.2
- @backstage/plugin-auth-node@0.6.14-next.2
- @backstage/plugin-permission-node@0.10.11-next.1
## 1.7.1-next.0
### Patch Changes
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
## 1.7.0
### Minor Changes
- f1d29b4: Added support for extension point factories. This makes it possible to call `registerExtensionPoint` with a single options argument and provide a factory for the extension point rather than a direct implementation. The factory is passed a context with a `reportModuleStartupFailure` method that makes it possible for plugins to report and attribute startup errors to the module that consumed the extension point.
- bb9b471: Plugin IDs that do not match the standard format are deprecated (letters, digits, and dashes only, starting with a letter). Plugin IDs that do no match this format will be rejected in a future release.
In addition, plugin IDs that don't match the legacy pattern that also allows underscores, with be rejected.
### Patch Changes
- 7455dae: Use node prefix on native imports
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/cli-common@0.1.18
- @backstage/plugin-auth-node@0.6.13
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.10
## 1.7.0-next.1
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-plugin-api",
"version": "1.7.0-next.1",
"version": "1.8.0-next.1",
"description": "Core API used by Backstage backend plugins",
"backstage": {
"role": "node-library"
@@ -5,6 +5,7 @@
```ts
import { AnyZodObject } from 'zod';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { BasicPermission } from '@backstage/plugin-permission-common';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
@@ -31,6 +32,7 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
visibilityPermission?: BasicPermission;
attributes?: {
destructive?: boolean;
idempotent?: boolean;
@@ -82,6 +84,7 @@ export interface ActionsService {
// @alpha (undocumented)
export type ActionsServiceAction = {
id: string;
pluginId: string;
name: string;
title: string;
description: string;
@@ -103,6 +106,150 @@ export const actionsServiceRef: ServiceRef<
'singleton'
>;
// @alpha
export interface MetricAdvice {
explicitBucketBoundaries?: number[];
}
// @alpha
export interface MetricAttributes {
// (undocumented)
[attributeKey: string]: MetricAttributeValue | undefined;
}
// @alpha
export type MetricAttributeValue =
| string
| number
| boolean
| Array<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
// @alpha
export interface MetricOptions {
advice?: MetricAdvice;
description?: string;
unit?: string;
}
// @alpha
export interface MetricsService {
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes>;
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes>;
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes>;
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
}
// @alpha
export interface MetricsServiceCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
add(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
record(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceHistogram<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
record(value: number, attributes?: TAttributes): void;
}
// @alpha
export interface MetricsServiceObservable<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
addCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
// (undocumented)
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
// @alpha
export type MetricsServiceObservableCallback<
TAttributes extends MetricAttributes = MetricAttributes,
> = (
observableResult: MetricsServiceObservableResult<TAttributes>,
) => void | Promise<void>;
// @alpha
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export interface MetricsServiceObservableResult<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
observe(value: number, attributes?: TAttributes): void;
}
// @alpha
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export const metricsServiceRef: ServiceRef<
MetricsService,
'plugin',
'singleton'
>;
// @alpha
export interface MetricsServiceUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
add(value: number, attributes?: TAttributes): void;
}
// @public (undocumented)
export interface RootSystemMetadataService {
// (undocumented)
@@ -643,6 +643,7 @@ export interface RootServiceFactoryOptions<
// @public
export interface SchedulerService {
cancelTask(id: string): Promise<void>;
createScheduledTaskRunner(
schedule: SchedulerServiceTaskScheduleDefinition,
): SchedulerServiceTaskRunner;
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { z, AnyZodObject } from 'zod';
import { BasicPermission } from '@backstage/plugin-permission-common';
import {
LoggerService,
BackstageCredentials,
@@ -42,6 +43,7 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
visibilityPermission?: BasicPermission;
attributes?: {
destructive?: boolean;
idempotent?: boolean;
@@ -22,6 +22,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api';
*/
export type ActionsServiceAction = {
id: string;
pluginId: string;
name: string;
title: string;
description: string;
@@ -0,0 +1,273 @@
/*
* Copyright 2026 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.
*/
/**
* Attribute values that can be attached to metric measurements.
*
* @alpha
*/
export type MetricAttributeValue =
| string
| number
| boolean
| Array<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
/**
* A set of key-value pairs that can be attached to metric measurements.
*
* @alpha
*/
export interface MetricAttributes {
[attributeKey: string]: MetricAttributeValue | undefined;
}
/**
* Advisory options that influence aggregation configuration.
*
* @alpha
*/
export interface MetricAdvice {
/**
* Hint the explicit bucket boundaries for histogram aggregation.
*/
explicitBucketBoundaries?: number[];
}
/**
* Options for creating a metric instrument.
*
* @alpha
*/
export interface MetricOptions {
/**
* The description of the Metric.
*/
description?: string;
/**
* The unit of the Metric values.
*/
unit?: string;
/**
* Advisory options that influence aggregation configuration.
*/
advice?: MetricAdvice;
}
/**
* A counter metric that only supports non-negative increments.
*
* @alpha
*/
export interface MetricsServiceCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
add(value: number, attributes?: TAttributes): void;
}
/**
* A counter metric that supports both positive and negative increments.
*
* @alpha
*/
export interface MetricsServiceUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> {
add(value: number, attributes?: TAttributes): void;
}
/**
* A histogram metric for recording distributions of values.
*
* @alpha
*/
export interface MetricsServiceHistogram<
TAttributes extends MetricAttributes = MetricAttributes,
> {
record(value: number, attributes?: TAttributes): void;
}
/**
* A gauge metric for recording instantaneous values.
*
* @alpha
*/
export interface MetricsServiceGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> {
record(value: number, attributes?: TAttributes): void;
}
/**
* The result object passed to observable metric callbacks.
*
* @alpha
*/
export interface MetricsServiceObservableResult<
TAttributes extends MetricAttributes = MetricAttributes,
> {
observe(value: number, attributes?: TAttributes): void;
}
/**
* A callback function for observable metrics. Called whenever a metric
* collection is initiated.
*
* @alpha
*/
export type MetricsServiceObservableCallback<
TAttributes extends MetricAttributes = MetricAttributes,
> = (
observableResult: MetricsServiceObservableResult<TAttributes>,
) => void | Promise<void>;
/**
* An observable metric instrument that reports values via callbacks.
*
* @alpha
*/
export interface MetricsServiceObservable<
TAttributes extends MetricAttributes = MetricAttributes,
> {
addCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
/**
* An observable counter metric that reports non-negative sums via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable counter metric that reports sums that can go up or down
* via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable gauge metric that reports instantaneous values via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* A service that provides a facility for emitting metrics.
*
* @alpha
*/
export interface MetricsService {
/**
* Creates a new counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The counter metric.
*/
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
/**
* Creates a new up-down counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The up-down counter metric.
*/
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
/**
* Creates a new histogram metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The histogram metric.
*/
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
/**
* Creates a new gauge metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The gauge metric.
*/
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
/**
* Creates a new observable counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable counter metric.
*/
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes>;
/**
* Creates a new observable up-down counter metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable up-down counter metric.
*/
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes>;
/**
* Creates a new observable gauge metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The observable gauge metric.
*/
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes>;
}
@@ -27,8 +27,27 @@ export type {
export type { ActionsService, ActionsServiceAction } from './ActionsService';
export type {
MetricsService,
MetricAdvice,
MetricAttributes,
MetricAttributeValue,
MetricOptions,
MetricsServiceCounter,
MetricsServiceUpDownCounter,
MetricsServiceHistogram,
MetricsServiceGauge,
MetricsServiceObservable,
MetricsServiceObservableCallback,
MetricsServiceObservableCounter,
MetricsServiceObservableGauge,
MetricsServiceObservableResult,
MetricsServiceObservableUpDownCounter,
} from './MetricsService';
export {
actionsRegistryServiceRef,
actionsServiceRef,
metricsServiceRef,
rootSystemMetadataServiceRef,
} from './refs';
@@ -56,3 +56,14 @@ export const rootSystemMetadataServiceRef = createServiceRef<
id: 'alpha.core.rootSystemMetadata',
scope: 'root',
});
/**
* Service for managing metrics.
*
* @alpha
*/
export const metricsServiceRef = createServiceRef<
import('./MetricsService').MetricsService
>({
id: 'alpha.core.metrics',
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { z, AnyZodObject } from 'zod';
import { BasicPermission } from '@backstage/plugin-permission-common';
import { LoggerService } from './LoggerService';
import { BackstageCredentials } from './AuthService';
@@ -40,6 +41,7 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
visibilityPermission?: BasicPermission;
attributes?: {
destructive?: boolean;
idempotent?: boolean;
@@ -304,6 +304,16 @@ export interface SchedulerService {
*/
triggerTask(id: string): Promise<void>;
/**
* Cancels a currently running task by ID, marking it as idle.
*
* If the task doesn't exist, a NotFoundError is thrown. If the task is
* not currently running, a ConflictError is thrown.
*
* @param id - The task ID
*/
cancelTask(id: string): Promise<void>;
/**
* Schedules a task function for recurring runs.
*
+64
View File
@@ -1,5 +1,69 @@
# @backstage/backend-test-utils
## 1.11.1-next.2
### Patch Changes
- 164711a: Added `cancelTask` to `MockSchedulerService` and mock scheduler service factory.
- Updated dependencies
- @backstage/backend-plugin-api@1.8.0-next.1
- @backstage/backend-defaults@0.16.0-next.2
- @backstage/backend-app-api@1.6.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.2
- @backstage/plugin-events-node@0.4.20-next.1
## 1.11.1-next.1
### Patch Changes
- 62f0a53: Fixed error forwarding in the actions registry so that known errors like `InputError` and `NotFoundError` thrown by actions preserve their original status codes and messages instead of being wrapped in `ForwardedError` and coerced to 500.
- Updated dependencies
- @backstage/backend-defaults@0.16.0-next.1
- @backstage/plugin-auth-node@0.6.14-next.1
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
## 1.11.1-next.0
### Patch Changes
- 1ee5b28: Adds a new metrics service mock to be leveraged in tests
- Updated dependencies
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
## 1.11.0
### Minor Changes
- 42abfb1: Added `createServiceMock`, a public utility for creating `ServiceMock` instances for custom service refs. This allows plugin authors to define mock creators for their own services following the same pattern as the built-in `mockServices` mocks.
### Patch Changes
- f1d29b4: Updated `startTestBackend` to support factory-based extension points (v1.1 format) in addition to the existing direct implementation format.
- 7455dae: Use node prefix on native imports
- 68eb322: Added `@types/jest` as an optional peer dependency, since jest types are exposed in the public API surface.
- 69d880e: Bump to latest zod to ensure it has the latest features
- Updated dependencies
- @backstage/backend-app-api@1.5.0
- @backstage/backend-plugin-api@1.7.0
- @backstage/backend-defaults@0.15.2
- @backstage/plugin-auth-node@0.6.13
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-events-node@0.4.19
## 1.11.0-next.1
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-test-utils",
"version": "1.11.0-next.1",
"version": "1.11.1-next.2",
"description": "Test helpers library for Backstage backends",
"backstage": {
"role": "node-library"
@@ -12,6 +12,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @alpha (undocumented)
@@ -43,6 +44,16 @@ export namespace actionsServiceMock {
) => ServiceMock<ActionsService>;
}
// @alpha (undocumented)
export namespace metricsServiceMock {
const // (undocumented)
factory: () => ServiceFactory<MetricsService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<MetricsService> | undefined,
) => ServiceMock<MetricsService>;
}
// @alpha
export class MockActionsRegistry
implements ActionsRegistryService, ActionsService
@@ -0,0 +1,59 @@
/*
* Copyright 2025 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 { createServiceMock } from './alphaCreateServiceMock';
import {
MetricsService,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { metricsServiceFactory } from '@backstage/backend-defaults/alpha';
/**
* @alpha
*/
export namespace metricsServiceMock {
export const factory = () => metricsServiceFactory;
export const mock = createServiceMock<MetricsService>(
metricsServiceRef,
() => ({
createCounter: jest.fn().mockImplementation(() => ({
add: jest.fn(),
})),
createUpDownCounter: jest.fn().mockImplementation(() => ({
add: jest.fn(),
})),
createHistogram: jest.fn().mockImplementation(() => ({
record: jest.fn(),
})),
createGauge: jest.fn().mockImplementation(() => ({
record: jest.fn(),
})),
createObservableCounter: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
createObservableUpDownCounter: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
createObservableGauge: jest.fn().mockImplementation(() => ({
addCallback: jest.fn(),
removeCallback: jest.fn(),
})),
}),
);
}
@@ -17,7 +17,7 @@ import {
BackstageCredentials,
LoggerService,
} from '@backstage/backend-plugin-api';
import { ForwardedError, InputError, NotFoundError } from '@backstage/errors';
import { InputError, NotFoundError } from '@backstage/errors';
import { JsonObject, JsonValue } from '@backstage/types';
import { z, AnyZodObject } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
@@ -82,6 +82,7 @@ export class MockActionsRegistry
return {
actions: Array.from(this.actions.entries()).map(([id, action]) => ({
id,
pluginId: 'test',
name: action.name,
title: action.title,
description: action.description,
@@ -126,31 +127,24 @@ export class MockActionsRegistry
throw new InputError(`Invalid input to action "${opts.id}"`, input.error);
}
try {
const result = await action.action({
input: input.data,
credentials: opts.credentials ?? mockCredentials.none(),
logger: this.logger,
});
const result = await action.action({
input: input.data,
credentials: opts.credentials ?? mockCredentials.none(),
logger: this.logger,
});
const output = action.schema?.output
? action.schema.output(z).safeParse(result?.output)
: ({ success: true, data: result?.output } as const);
const output = action.schema?.output
? action.schema.output(z).safeParse(result?.output)
: ({ success: true, data: result?.output } as const);
if (!output.success) {
throw new InputError(
`Invalid output from action "${opts.id}"`,
output.error,
);
}
return { output: output.data };
} catch (error) {
throw new ForwardedError(
`Failed execution of action "${opts.id}"`,
error,
if (!output.success) {
throw new InputError(
`Invalid output from action "${opts.id}"`,
output.error,
);
}
return { output: output.data };
}
register<
@@ -17,4 +17,5 @@
export { actionsRegistryServiceMock } from './ActionsRegistryServiceMock';
export { MockActionsRegistry } from './MockActionsRegistry';
export { actionsServiceMock } from './ActionsServiceMock';
export { metricsServiceMock } from './MetricsServiceMock';
export { type ServiceMock } from './alphaCreateServiceMock';
@@ -206,6 +206,68 @@ describe('MockSchedulerService', () => {
await expect(isDone()).resolves.toBe(true);
});
it('should cancel a running task and allow re-triggering with a fresh signal', async () => {
const scheduler = new MockSchedulerService();
const signals: AbortSignal[] = [];
scheduler.scheduleTask({
...baseOpts,
id: 'test',
fn: async signal => {
signals.push(signal);
// Simulate long-running work that respects cancellation
await new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new Error('aborted'));
return;
}
signal.addEventListener('abort', () => reject(new Error('aborted')));
setTimeout(1).then(resolve);
});
},
});
// First run completes normally
await scheduler.triggerTask('test');
expect(signals).toHaveLength(1);
expect(signals[0].aborted).toBe(false);
// Start a task that will block until cancelled
const blockingScheduler = new MockSchedulerService();
let resolveBlock: (() => void) | undefined;
blockingScheduler.scheduleTask({
...baseOpts,
id: 'blocking',
fn: async signal => {
signals.push(signal);
await new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
resolveBlock = resolve;
});
},
});
const triggerPromise = blockingScheduler.triggerTask('blocking');
// Give the task fn time to start
await setTimeout(1);
await blockingScheduler.cancelTask('blocking');
await triggerPromise.catch(() => {});
expect(signals).toHaveLength(2);
expect(signals[1].aborted).toBe(true);
// Re-trigger should get a fresh non-aborted signal
resolveBlock = undefined;
const triggerPromise2 = blockingScheduler.triggerTask('blocking');
await setTimeout(1);
resolveBlock!();
await triggerPromise2;
expect(signals).toHaveLength(3);
expect(signals[2].aborted).toBe(false);
});
it('should abort tasks when shutting down', async () => {
let taskSignal: AbortSignal | undefined;
@@ -23,6 +23,7 @@ import {
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { createDeferred, DeferredPromise } from '@backstage/types';
export class MockSchedulerService implements SchedulerService {
@@ -95,10 +96,22 @@ export class MockSchedulerService implements SchedulerService {
});
}
async cancelTask(id: string): Promise<void> {
const task = this.#tasks.get(id);
if (!task) {
throw new NotFoundError(`Task ${id} not found`);
}
if (!this.#runningTasks.has(id)) {
throw new ConflictError(`Task ${id} is not running`);
}
task.abortControllers.abort();
task.abortControllers = new AbortController();
}
async triggerTask(id: string): Promise<void> {
const task = this.#tasks.get(id);
if (!task) {
throw new Error(`Task ${id} not found`);
throw new NotFoundError(`Task ${id} not found`);
}
if (this.#runningTasks.has(id)) {
return;
@@ -526,6 +526,7 @@ export namespace mockServices {
getScheduledTasks: jest.fn(),
scheduleTask: jest.fn(),
triggerTask: jest.fn(),
cancelTask: jest.fn(),
}));
}
@@ -43,6 +43,7 @@ import { HostDiscovery } from '@backstage/backend-defaults/discovery';
import {
actionsRegistryServiceMock,
actionsServiceMock,
metricsServiceMock,
} from '../alpha/services';
/** @public */
@@ -92,6 +93,7 @@ export const defaultServiceFactories = [
// Alpha services
actionsRegistryServiceMock.factory(),
actionsServiceMock.factory(),
metricsServiceMock.factory(),
];
/**

Some files were not shown because too many files have changed in this diff Show More