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:
@@ -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,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"
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+97
-31
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+339
-5
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-1
@@ -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());
|
||||
|
||||
+117
@@ -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';
|
||||
+130
@@ -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';
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+96
@@ -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');
|
||||
|
||||
+42
-13
@@ -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';
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
+28
@@ -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;
|
||||
+22
@@ -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,
|
||||
});
|
||||
+6
@@ -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"
|
||||
}
|
||||
+31
@@ -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
-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
-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
-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
-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
-2
@@ -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
-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
-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
-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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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,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.
|
||||
*
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user