Merge branch 'master' of https://github.com/backstage/backstage into star-color
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# @backstage/app-defaults
|
||||
|
||||
## 1.5.11-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.14.11-next.0
|
||||
- @backstage/core-app-api@1.14.2
|
||||
- @backstage/core-plugin-api@1.9.3
|
||||
- @backstage/theme@0.5.6
|
||||
- @backstage/plugin-permission-react@0.4.25
|
||||
|
||||
## 1.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/app-defaults",
|
||||
"version": "1.5.10",
|
||||
"version": "1.5.11-next.0",
|
||||
"description": "Provides the default wiring of a Backstage App",
|
||||
"backstage": {
|
||||
"role": "web-library"
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# app-next-example-plugin
|
||||
|
||||
## 0.0.15-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/frontend-plugin-api@0.8.0-next.1
|
||||
- @backstage/core-components@0.14.11-next.0
|
||||
|
||||
## 0.0.15-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/frontend-plugin-api@0.8.0-next.0
|
||||
- @backstage/core-components@0.14.10
|
||||
|
||||
## 0.0.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,44 +4,43 @@
|
||||
|
||||
```ts
|
||||
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { default as React_2 } from 'react';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
const examplePlugin: BackstagePlugin<
|
||||
const examplePlugin: FrontendPlugin<
|
||||
{},
|
||||
{},
|
||||
{
|
||||
'page:example': ExtensionDefinition<
|
||||
{
|
||||
'page:example': ExtensionDefinition<{
|
||||
kind: 'page';
|
||||
namespace: undefined;
|
||||
name: undefined;
|
||||
config: {
|
||||
path: string | undefined;
|
||||
},
|
||||
{
|
||||
};
|
||||
configInput: {
|
||||
path?: string | undefined;
|
||||
},
|
||||
| ConfigurableExtensionDataRef<
|
||||
React_2.JSX.Element,
|
||||
'core.reactElement',
|
||||
{}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ConfigurableExtensionDataRef<
|
||||
RouteRef<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>,
|
||||
{},
|
||||
{
|
||||
kind: 'page';
|
||||
namespace: undefined;
|
||||
name: undefined;
|
||||
}
|
||||
>;
|
||||
};
|
||||
output:
|
||||
| ConfigurableExtensionDataRef<
|
||||
React_2.JSX.Element,
|
||||
'core.reactElement',
|
||||
{}
|
||||
>
|
||||
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ConfigurableExtensionDataRef<
|
||||
RouteRef<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default examplePlugin;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-next-example-plugin",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.15-next.1",
|
||||
"description": "Backstage internal example plugin",
|
||||
"backstage": {
|
||||
"role": "frontend-plugin",
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
# example-app-next
|
||||
|
||||
## 0.0.15-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/cli@0.27.1-next.1
|
||||
- @backstage/frontend-defaults@0.1.0-next.0
|
||||
- @backstage/frontend-app-api@0.9.0-next.1
|
||||
- @backstage/frontend-plugin-api@0.8.0-next.1
|
||||
- @backstage/core-compat-api@0.3.0-next.1
|
||||
- @backstage/plugin-app@0.1.0-next.1
|
||||
- @backstage/core-components@0.14.11-next.0
|
||||
- @backstage/plugin-catalog-graph@0.4.9-next.1
|
||||
- @backstage/plugin-scaffolder-react@1.12.0-next.1
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.8-next.0
|
||||
- @backstage/plugin-signals@0.0.10-next.1
|
||||
- @backstage/plugin-catalog-react@1.12.4-next.1
|
||||
- @backstage/app-defaults@1.5.11-next.0
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/core-app-api@1.14.2
|
||||
- @backstage/core-plugin-api@1.9.3
|
||||
- @backstage/integration-react@1.1.30
|
||||
- @backstage/theme@0.5.6
|
||||
- @backstage/plugin-api-docs@0.11.9-next.1
|
||||
- @backstage/plugin-app-visualizer@0.1.10-next.1
|
||||
- @backstage/plugin-auth-react@0.1.6-next.0
|
||||
- @backstage/plugin-catalog@1.22.1-next.1
|
||||
- @backstage/plugin-catalog-common@1.0.26
|
||||
- @backstage/plugin-catalog-import@0.12.3-next.1
|
||||
- @backstage/plugin-home@0.7.10-next.1
|
||||
- @backstage/plugin-kubernetes@0.11.14-next.1
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.15-next.1
|
||||
- @backstage/plugin-notifications@0.3.1-next.1
|
||||
- @backstage/plugin-org@0.6.29-next.1
|
||||
- @backstage/plugin-permission-react@0.4.25
|
||||
- @backstage/plugin-scaffolder@1.25.0-next.1
|
||||
- @backstage/plugin-search@1.4.16-next.1
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
- @backstage/plugin-search-react@1.8.0-next.1
|
||||
- @backstage/plugin-techdocs@1.10.9-next.1
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.14-next.1
|
||||
- @backstage/plugin-techdocs-react@1.2.8-next.1
|
||||
- @backstage/plugin-user-settings@0.8.12-next.1
|
||||
|
||||
## 0.0.15-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/frontend-plugin-api@0.8.0-next.0
|
||||
- @backstage/plugin-techdocs-react@1.2.8-next.0
|
||||
- @backstage/frontend-app-api@0.9.0-next.0
|
||||
- @backstage/core-compat-api@0.2.9-next.0
|
||||
- @backstage/plugin-catalog-import@0.12.3-next.0
|
||||
- @backstage/plugin-catalog-graph@0.4.9-next.0
|
||||
- @backstage/plugin-catalog-react@1.12.4-next.0
|
||||
- @backstage/plugin-user-settings@0.8.12-next.0
|
||||
- @backstage/plugin-search-react@1.8.0-next.0
|
||||
- @backstage/plugin-kubernetes@0.11.14-next.0
|
||||
- @backstage/plugin-scaffolder@1.25.0-next.0
|
||||
- @backstage/plugin-api-docs@0.11.9-next.0
|
||||
- @backstage/plugin-techdocs@1.10.9-next.0
|
||||
- @backstage/plugin-catalog@1.22.1-next.0
|
||||
- @backstage/plugin-search@1.4.16-next.0
|
||||
- @backstage/plugin-home@0.7.10-next.0
|
||||
- @backstage/plugin-org@0.6.29-next.0
|
||||
- @backstage/plugin-scaffolder-react@1.12.0-next.0
|
||||
- @backstage/plugin-app@0.1.0-next.0
|
||||
- @backstage/cli@0.27.1-next.0
|
||||
- @backstage/plugin-signals@0.0.10-next.0
|
||||
- @backstage/plugin-app-visualizer@0.1.10-next.0
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.14-next.0
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.15-next.0
|
||||
- @backstage/app-defaults@1.5.10
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/core-app-api@1.14.2
|
||||
- @backstage/core-components@0.14.10
|
||||
- @backstage/core-plugin-api@1.9.3
|
||||
- @backstage/integration-react@1.1.30
|
||||
- @backstage/theme@0.5.6
|
||||
- @backstage/plugin-auth-react@0.1.5
|
||||
- @backstage/plugin-catalog-common@1.0.26
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.7
|
||||
- @backstage/plugin-notifications@0.3.1-next.0
|
||||
- @backstage/plugin-permission-react@0.4.25
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
|
||||
## 0.0.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app-next",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.15-next.1",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,9 +21,11 @@
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-app-api": "workspace:^",
|
||||
"@backstage/frontend-defaults": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/integration-react": "workspace:^",
|
||||
"@backstage/plugin-api-docs": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/plugin-app-visualizer": "workspace:^",
|
||||
"@backstage/plugin-auth-react": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
@@ -53,16 +55,12 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@octokit/rest": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"history": "^5.0.0",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-use": "^17.2.4",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -26,8 +26,8 @@ import homePlugin, {
|
||||
import {
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
createExtensionOverrides,
|
||||
ApiBlueprint,
|
||||
createFrontendModule,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
techdocsPlugin,
|
||||
@@ -45,11 +45,10 @@ import { createApiFactory, configApiRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
ScmAuth,
|
||||
ScmIntegrationsApi,
|
||||
scmAuthApiRef,
|
||||
scmIntegrationsApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
|
||||
import { signInPageOverrides } from './overrides/SignInPage';
|
||||
import { signInPageModule } from './overrides/SignInPage';
|
||||
import { convertLegacyPlugin } from '@backstage/core-compat-api';
|
||||
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
|
||||
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
|
||||
@@ -97,34 +96,48 @@ const convertedTechdocsPlugin = convertLegacyPlugin(techdocsPlugin, {
|
||||
],
|
||||
});
|
||||
|
||||
const homePageExtension = createExtension({
|
||||
name: 'myhomepage',
|
||||
attachTo: { id: 'page:home', input: 'props' },
|
||||
output: [coreExtensionData.reactElement, titleExtensionDataRef],
|
||||
factory() {
|
||||
return [
|
||||
coreExtensionData.reactElement(homePage),
|
||||
titleExtensionDataRef('just a title'),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const scmAuthExtension = ApiBlueprint.make({
|
||||
namespace: scmAuthApiRef.id,
|
||||
params: {
|
||||
factory: ScmAuth.createDefaultApiFactory(),
|
||||
},
|
||||
});
|
||||
|
||||
const scmIntegrationApi = ApiBlueprint.make({
|
||||
namespace: scmIntegrationsApiRef.id,
|
||||
params: {
|
||||
factory: createApiFactory({
|
||||
api: scmIntegrationsApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
|
||||
const customHomePageModule = createFrontendModule({
|
||||
pluginId: 'home',
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'my-home-page',
|
||||
attachTo: { id: 'page:home', input: 'props' },
|
||||
output: [coreExtensionData.reactElement, titleExtensionDataRef],
|
||||
factory() {
|
||||
return [
|
||||
coreExtensionData.reactElement(homePage),
|
||||
titleExtensionDataRef('just a title'),
|
||||
];
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const scmModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'scm-auth',
|
||||
params: {
|
||||
factory: ScmAuth.createDefaultApiFactory(),
|
||||
},
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'scm-integrations',
|
||||
params: {
|
||||
factory: createApiFactory({
|
||||
api: scmIntegrationsApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const notFoundErrorPageModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [notFoundErrorPage],
|
||||
});
|
||||
|
||||
const collectedLegacyPlugins = convertLegacyApp(
|
||||
@@ -141,16 +154,11 @@ const app = createApp({
|
||||
homePlugin,
|
||||
appVisualizerPlugin,
|
||||
kubernetesPlugin,
|
||||
signInPageOverrides,
|
||||
signInPageModule,
|
||||
scmModule,
|
||||
notFoundErrorPageModule,
|
||||
customHomePageModule,
|
||||
...collectedLegacyPlugins,
|
||||
createExtensionOverrides({
|
||||
extensions: [
|
||||
homePageExtension,
|
||||
scmAuthExtension,
|
||||
scmIntegrationApi,
|
||||
notFoundErrorPage,
|
||||
],
|
||||
}),
|
||||
],
|
||||
/* Handled through config instead */
|
||||
// bindRoutes({ bind }) {
|
||||
|
||||
@@ -52,6 +52,7 @@ export function CustomNotFoundErrorPage() {
|
||||
}
|
||||
|
||||
export default createComponentExtension({
|
||||
name: 'not-found-error-page',
|
||||
ref: coreComponentRefs.notFoundErrorPage,
|
||||
loader: { sync: () => CustomNotFoundErrorPage },
|
||||
});
|
||||
|
||||
@@ -14,32 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
|
||||
import { createApp } from '@backstage/frontend-app-api';
|
||||
import { signInPageOverrides } from './overrides/SignInPage';
|
||||
import {
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
createExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { signInPageModule } from './overrides/SignInPage';
|
||||
import { createPublicSignInApp } from '@backstage/frontend-defaults';
|
||||
|
||||
const authRedirectExtension = createExtension({
|
||||
namespace: 'app',
|
||||
name: 'layout',
|
||||
attachTo: { id: 'app/root', input: 'children' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<CookieAuthRedirect />)],
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
signInPageOverrides,
|
||||
createExtensionOverrides({
|
||||
extensions: [authRedirectExtension],
|
||||
}),
|
||||
],
|
||||
const app = createPublicSignInApp({
|
||||
features: [signInPageModule],
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot());
|
||||
|
||||
@@ -18,7 +18,7 @@ import React from 'react';
|
||||
import { SignInPage } from '@backstage/core-components';
|
||||
import {
|
||||
SignInPageBlueprint,
|
||||
createExtensionOverrides,
|
||||
createFrontendModule,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
const signInPage = SignInPageBlueprint.make({
|
||||
@@ -29,6 +29,7 @@ const signInPage = SignInPageBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
export const signInPageOverrides = createExtensionOverrides({
|
||||
export const signInPageModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [signInPage],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# example-app
|
||||
|
||||
## 0.2.101-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/cli@0.27.1-next.1
|
||||
- @backstage/frontend-app-api@0.9.0-next.1
|
||||
- @backstage/core-components@0.14.11-next.0
|
||||
- @backstage/plugin-catalog-graph@0.4.9-next.1
|
||||
- @backstage/plugin-scaffolder-react@1.12.0-next.1
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.8-next.0
|
||||
- @backstage/plugin-signals@0.0.10-next.1
|
||||
- @backstage/plugin-catalog-react@1.12.4-next.1
|
||||
- @backstage/app-defaults@1.5.11-next.0
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/core-app-api@1.14.2
|
||||
- @backstage/core-plugin-api@1.9.3
|
||||
- @backstage/integration-react@1.1.30
|
||||
- @backstage/theme@0.5.6
|
||||
- @backstage/plugin-api-docs@0.11.9-next.1
|
||||
- @backstage/plugin-auth-react@0.1.6-next.0
|
||||
- @backstage/plugin-catalog@1.22.1-next.1
|
||||
- @backstage/plugin-catalog-common@1.0.26
|
||||
- @backstage/plugin-catalog-import@0.12.3-next.1
|
||||
- @backstage/plugin-devtools@0.1.18-next.1
|
||||
- @backstage/plugin-home@0.7.10-next.1
|
||||
- @backstage/plugin-kubernetes@0.11.14-next.1
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.15-next.1
|
||||
- @backstage/plugin-notifications@0.3.1-next.1
|
||||
- @backstage/plugin-org@0.6.29-next.1
|
||||
- @backstage/plugin-permission-react@0.4.25
|
||||
- @backstage/plugin-scaffolder@1.25.0-next.1
|
||||
- @backstage/plugin-search@1.4.16-next.1
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
- @backstage/plugin-search-react@1.8.0-next.1
|
||||
- @backstage/plugin-techdocs@1.10.9-next.1
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.14-next.1
|
||||
- @backstage/plugin-techdocs-react@1.2.8-next.1
|
||||
- @backstage/plugin-user-settings@0.8.12-next.1
|
||||
|
||||
## 0.2.101-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-techdocs-react@1.2.8-next.0
|
||||
- @backstage/frontend-app-api@0.9.0-next.0
|
||||
- @backstage/plugin-catalog-import@0.12.3-next.0
|
||||
- @backstage/plugin-catalog-graph@0.4.9-next.0
|
||||
- @backstage/plugin-catalog-react@1.12.4-next.0
|
||||
- @backstage/plugin-user-settings@0.8.12-next.0
|
||||
- @backstage/plugin-search-react@1.8.0-next.0
|
||||
- @backstage/plugin-kubernetes@0.11.14-next.0
|
||||
- @backstage/plugin-scaffolder@1.25.0-next.0
|
||||
- @backstage/plugin-api-docs@0.11.9-next.0
|
||||
- @backstage/plugin-devtools@0.1.18-next.0
|
||||
- @backstage/plugin-techdocs@1.10.9-next.0
|
||||
- @backstage/plugin-catalog@1.22.1-next.0
|
||||
- @backstage/plugin-search@1.4.16-next.0
|
||||
- @backstage/plugin-home@0.7.10-next.0
|
||||
- @backstage/plugin-org@0.6.29-next.0
|
||||
- @backstage/plugin-scaffolder-react@1.12.0-next.0
|
||||
- @backstage/cli@0.27.1-next.0
|
||||
- @backstage/plugin-signals@0.0.10-next.0
|
||||
- @backstage/plugin-techdocs-module-addons-contrib@1.1.14-next.0
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.15-next.0
|
||||
- @backstage/app-defaults@1.5.10
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/core-app-api@1.14.2
|
||||
- @backstage/core-components@0.14.10
|
||||
- @backstage/core-plugin-api@1.9.3
|
||||
- @backstage/integration-react@1.1.30
|
||||
- @backstage/theme@0.5.6
|
||||
- @backstage/plugin-auth-react@0.1.5
|
||||
- @backstage/plugin-catalog-common@1.0.26
|
||||
- @backstage/plugin-catalog-unprocessed-entities@0.2.7
|
||||
- @backstage/plugin-notifications@0.3.1-next.0
|
||||
- @backstage/plugin-permission-react@0.4.25
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
|
||||
## 0.2.100
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.2.100",
|
||||
"version": "0.2.101-next.1",
|
||||
"backstage": {
|
||||
"role": "frontend"
|
||||
},
|
||||
@@ -74,16 +74,12 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@octokit/rest": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"history": "^5.0.0",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-use": "^17.2.4",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -73,8 +73,7 @@ import { DelayingComponentFieldExtension } from './components/scaffolder/customS
|
||||
import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTemplate';
|
||||
import { searchPage } from './components/search/SearchPage';
|
||||
import { providers } from './identityProviders';
|
||||
import * as plugins from './plugins';
|
||||
|
||||
import { SignalsDisplay } from '@backstage/plugin-signals';
|
||||
import { techDocsPage } from './components/techdocs/TechDocsPage';
|
||||
import { RequirePermission } from '@backstage/plugin-permission-react';
|
||||
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
|
||||
@@ -86,7 +85,6 @@ import { NotificationsPage } from '@backstage/plugin-notifications';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
plugins: Object.values(plugins),
|
||||
icons: {
|
||||
// Custom icon example
|
||||
alert: AlarmIcon,
|
||||
@@ -217,6 +215,7 @@ export default app.createRoot(
|
||||
<>
|
||||
<AlertDisplay transientTimeoutMs={2500} />
|
||||
<OAuthRequestDialog />
|
||||
<SignalsDisplay />
|
||||
<AppRouter>
|
||||
<VisitListener />
|
||||
<Root>{routes}</Root>
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# @backstage/backend-app-api
|
||||
|
||||
## 0.10.0-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c246372: Updated the error message for missing service dependencies to include the plugin and module IDs.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.25.0-next.1
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-permission-node@0.8.3-next.1
|
||||
|
||||
## 0.10.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 19ff127: **BREAKING**: The deprecated `identityServiceFactory` and `tokenManagerServiceFactory` have been removed.
|
||||
- d425fc4: **BREAKING**: The return values from `createBackendPlugin`, `createBackendModule`, and `createServiceFactory` are now simply `BackendFeature` and `ServiceFactory`, instead of the previously deprecated form of a function that returns them. For this reason, `createServiceFactory` also no longer accepts the callback form where you provide direct options to the service. This also affects all `coreServices.*` service refs.
|
||||
|
||||
This may in particular affect tests; if you were effectively doing `createBackendModule({...})()` (note the parentheses), you can now remove those extra parentheses at the end. You may encounter cases of this in your `packages/backend/src/index.ts` too, where you add plugins, modules, and services. If you were using `createServiceFactory` with a function as its argument for the purpose of passing in options, this pattern has been deprecated for a while and is no longer supported. You may want to explore the new multiton patterns to achieve your goals, or moving settings to app-config.
|
||||
|
||||
As part of this change, the `IdentityFactoryOptions` type was removed, and can no longer be used to tweak that service. The identity service was also deprecated some time ago, and you will want to [migrate to the new auth system](https://backstage.io/docs/tutorials/auth-service-migration) if you still rely on it.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd38da8: Deprecate the `featureDiscoveryServiceFactory` in favor of using `@backstage/backend-defaults#discoveryFeatureLoader` instead.
|
||||
- 7f779c7: `auth.externalAccess` should be optional in the config schema
|
||||
- 51a69b5: Fix feature loaders in CJS double-default nested builds
|
||||
- 0b2a402: Updates to the config schema to match reality
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/plugin-permission-node@0.8.3-next.0
|
||||
- @backstage/backend-common@0.25.0-next.0
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @alpha @deprecated (undocumented)
|
||||
export const featureDiscoveryServiceFactory: ServiceFactory<
|
||||
FeatureDiscoveryService,
|
||||
'root',
|
||||
|
||||
Vendored
+7
-292
@@ -16,297 +16,12 @@
|
||||
|
||||
export interface Config {
|
||||
backend?: {
|
||||
auth?: {
|
||||
/**
|
||||
* This disables the otherwise default auth policy, which requires all
|
||||
* requests to be authenticated with either user or service credentials.
|
||||
*
|
||||
* Disabling this check means that the backend will no longer block
|
||||
* unauthenticated requests, but instead allow them to pass through to
|
||||
* plugins.
|
||||
*
|
||||
* If permissions are enabled, unauthenticated requests will be treated
|
||||
* exactly as such, leaving it to the permission policy to determine what
|
||||
* permissions should be allowed for an unauthenticated identity. Note
|
||||
* that this will also apply to service-to-service calls between plugins
|
||||
* unless you configure credentials for service calls.
|
||||
*/
|
||||
dangerouslyDisableDefaultAuthPolicy?: boolean;
|
||||
|
||||
/** Controls how to store keys for plugin-to-plugin auth */
|
||||
pluginKeyStore?:
|
||||
| { type: 'database' }
|
||||
| {
|
||||
type: 'static';
|
||||
static: {
|
||||
/**
|
||||
* Must be declared at least once and the first one will be used for signing.
|
||||
*/
|
||||
keys: Array<{
|
||||
/**
|
||||
* Path to the public key file in the SPKI format. Should be an absolute path.
|
||||
*/
|
||||
publicKeyFile: string;
|
||||
/**
|
||||
* Path to the matching private key file in the PKCS#8 format. Should be an absolute path.
|
||||
*
|
||||
* The first array entry must specify a private key file, the rest must not.
|
||||
*/
|
||||
privateKeyFile?: string;
|
||||
/**
|
||||
* ID to uniquely identify this key within the JWK set.
|
||||
*/
|
||||
keyId: string;
|
||||
/**
|
||||
* JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
|
||||
* Must match the algorithm used to generate the keys in the provided files
|
||||
*/
|
||||
algorithm?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Configures methods of external access, ie ways for callers outside of
|
||||
* the Backstage ecosystem to get authorized for access to APIs that do
|
||||
* not permit unauthorized access.
|
||||
*/
|
||||
externalAccess?: Array<
|
||||
| {
|
||||
/**
|
||||
* This is the legacy service-to-service access method, where a set
|
||||
* of static keys were shared among plugins and used for symmetric
|
||||
* signing and verification. These correspond to the old
|
||||
* `backend.auth.keys` set and retain their behavior for backwards
|
||||
* compatibility. Please migrate to other access methods when
|
||||
* possible.
|
||||
*
|
||||
* Callers generate JWT tokens with the following payload:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "sub": "backstage-plugin",
|
||||
* "exp": <epoch seconds one hour in the future>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* And sign them with HS256, using the base64 decoded secret. The
|
||||
* tokens are then passed along with requests in the Authorization
|
||||
* header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eyJhbGciOiJIUzI...
|
||||
* ```
|
||||
*/
|
||||
type: 'legacy';
|
||||
options: {
|
||||
/**
|
||||
* Any set of base64 encoded random bytes to be used as both the
|
||||
* signing and verification key. Should be sufficiently long so as
|
||||
* not to be easy to guess by brute force.
|
||||
*
|
||||
* Can be generated eg using
|
||||
*
|
||||
* ```sh
|
||||
* node -p 'require("crypto").randomBytes(24).toString("base64")'
|
||||
* ```
|
||||
*
|
||||
* @visibility secret
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* Sets the subject of the principal, when matching this token.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* This access method consists of random static tokens that can be
|
||||
* handed out to callers.
|
||||
*
|
||||
* The tokens are then passed along verbatim with requests in the
|
||||
* Authorization header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
|
||||
* ```
|
||||
*/
|
||||
type: 'static';
|
||||
options: {
|
||||
/**
|
||||
* A raw token that can be any string, but for security reasons
|
||||
* should be sufficiently long so as not to be easy to guess by
|
||||
* brute force.
|
||||
*
|
||||
* Can be generated eg using
|
||||
*
|
||||
* ```sh
|
||||
* node -p 'require("crypto").randomBytes(24).toString("base64")'
|
||||
* ```
|
||||
*
|
||||
* Since the tokens can be any string, you are free to add
|
||||
* additional identifying data to them if you like. For example,
|
||||
* adding a `freben-local-dev-` prefix for debugging purposes to a
|
||||
* token that you know will be handed out for use as a personal
|
||||
* access token during development.
|
||||
*
|
||||
* @visibility secret
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Sets the subject of the principal, when matching this token.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* This access method consists of a JWKS endpoint that can be used to
|
||||
* verify JWT tokens.
|
||||
*
|
||||
* Callers generate JWT tokens via 3rd party tooling
|
||||
* and pass them in the Authorization header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
|
||||
* ```
|
||||
*/
|
||||
type: 'jwks';
|
||||
options: {
|
||||
/**
|
||||
* The full URL of the JWKS endpoint.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Sets the algorithm(s) that should be used to verify the JWT tokens.
|
||||
* The passed JWTs must have been signed using one of the listed algorithms.
|
||||
*/
|
||||
algorithm?: string | string[];
|
||||
/**
|
||||
* Sets the issuer(s) that should be used to verify the JWT tokens.
|
||||
* Passed JWTs must have an `iss` claim which matches one of the specified issuers.
|
||||
*/
|
||||
issuer?: string | string[];
|
||||
/**
|
||||
* Sets the audience(s) that should be used to verify the JWT tokens.
|
||||
* The passed JWTs must have an "aud" claim that matches one of the audiences specified,
|
||||
* or have no audience specified.
|
||||
*/
|
||||
audience?: string | string[];
|
||||
/**
|
||||
* Sets an optional subject prefix. Passes the subject to called plugins.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subjectPrefix?: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
packages?: 'all' | { include?: string[]; exclude?: string[] };
|
||||
};
|
||||
|
||||
/** Discovery options. */
|
||||
discovery?: {
|
||||
/**
|
||||
* Endpoints
|
||||
*
|
||||
* A list of target baseUrls and the associated plugins.
|
||||
*/
|
||||
endpoints: {
|
||||
/**
|
||||
* The target baseUrl to use for the plugin
|
||||
*
|
||||
* Can be either a string or an object with internal and external keys.
|
||||
* Targets with `{{pluginId}}` or `{{ pluginId }} in the url will be replaced with the pluginId.
|
||||
*/
|
||||
target: string | { internal: string; external: string };
|
||||
/** Array of plugins which use the target baseUrl. */
|
||||
plugins: string[];
|
||||
}[];
|
||||
/** Used by the feature discovery service */
|
||||
packages?:
|
||||
| 'all'
|
||||
| {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-app-api",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0-next.1",
|
||||
"description": "Core API used by Backstage backend apps",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -53,7 +53,6 @@
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/config-loader": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
@@ -66,7 +65,6 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"helmet": "^6.0.0",
|
||||
"jose": "^5.0.0",
|
||||
"knex": "^3.0.0",
|
||||
@@ -91,7 +89,6 @@
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/http-errors": "^2.0.0",
|
||||
"@types/minimist": "^1.2.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
|
||||
@@ -15,145 +15,17 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BackendFeature,
|
||||
RootConfigService,
|
||||
RootLoggerService,
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
featureDiscoveryServiceRef,
|
||||
FeatureDiscoveryService,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { featureDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { PackageDiscoveryService } from '../../../backend-defaults/src/PackageDiscoveryService';
|
||||
|
||||
const DETECTED_PACKAGE_ROLES = [
|
||||
'node-library',
|
||||
'backend',
|
||||
'backend-plugin',
|
||||
'backend-plugin-module',
|
||||
];
|
||||
|
||||
/** @internal */
|
||||
async function findClosestPackageDir(
|
||||
searchDir: string,
|
||||
): Promise<string | undefined> {
|
||||
let path = searchDir;
|
||||
|
||||
// Some confidence check to avoid infinite loop
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const packagePath = resolvePath(path, 'package.json');
|
||||
const exists = await fs.pathExists(packagePath);
|
||||
if (exists) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const newPath = dirname(path);
|
||||
if (newPath === path) {
|
||||
return undefined;
|
||||
}
|
||||
path = newPath;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Iteration limit reached when searching for root package.json at ${searchDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
class PackageDiscoveryService implements FeatureDiscoveryService {
|
||||
constructor(
|
||||
private readonly config: RootConfigService,
|
||||
private readonly logger: RootLoggerService,
|
||||
) {}
|
||||
|
||||
getDependencyNames(path: string) {
|
||||
const { dependencies } = require(path) as BackstagePackageJson;
|
||||
const packagesConfig = this.config.getOptional('backend.packages');
|
||||
|
||||
const dependencyNames = Object.keys(dependencies || {});
|
||||
|
||||
if (packagesConfig === 'all') {
|
||||
return dependencyNames;
|
||||
}
|
||||
|
||||
const includedPackagesConfig = this.config.getOptionalStringArray(
|
||||
'backend.packages.include',
|
||||
);
|
||||
|
||||
const includedPackages = includedPackagesConfig
|
||||
? new Set(includedPackagesConfig)
|
||||
: dependencyNames;
|
||||
const excludedPackagesSet = new Set(
|
||||
this.config.getOptionalStringArray('backend.packages.exclude'),
|
||||
);
|
||||
|
||||
return [...includedPackages].filter(name => !excludedPackagesSet.has(name));
|
||||
}
|
||||
|
||||
async getBackendFeatures(): Promise<{ features: Array<BackendFeature> }> {
|
||||
const packagesConfig = this.config.getOptional('backend.packages');
|
||||
if (!packagesConfig || Object.keys(packagesConfig).length === 0) {
|
||||
return { features: [] };
|
||||
}
|
||||
|
||||
const packageDir = await findClosestPackageDir(process.argv[1]);
|
||||
if (!packageDir) {
|
||||
throw new Error('Package discovery failed to find package.json');
|
||||
}
|
||||
const dependencyNames = this.getDependencyNames(
|
||||
resolvePath(packageDir, 'package.json'),
|
||||
);
|
||||
|
||||
const features: BackendFeature[] = [];
|
||||
|
||||
for (const name of dependencyNames) {
|
||||
const depPkg = require(require.resolve(`${name}/package.json`, {
|
||||
paths: [packageDir],
|
||||
})) as BackstagePackageJson;
|
||||
if (
|
||||
!depPkg?.backstage?.role ||
|
||||
!DETECTED_PACKAGE_ROLES.includes(depPkg.backstage.role)
|
||||
) {
|
||||
continue; // Not a backstage backend package, ignore
|
||||
}
|
||||
|
||||
const exportedModulePaths = [
|
||||
require.resolve(name, {
|
||||
paths: [packageDir],
|
||||
}),
|
||||
];
|
||||
|
||||
// Find modules exported as alpha
|
||||
try {
|
||||
exportedModulePaths.push(
|
||||
require.resolve(`${name}/alpha`, { paths: [packageDir] }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
for (const modulePath of exportedModulePaths) {
|
||||
const mod = require(modulePath);
|
||||
|
||||
if (isBackendFeature(mod.default)) {
|
||||
this.logger.info(`Detected: ${name}`);
|
||||
features.push(mod.default);
|
||||
}
|
||||
if (isBackendFeatureFactory(mod.default)) {
|
||||
this.logger.info(`Detected: ${name}`);
|
||||
features.push(mod.default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { features };
|
||||
}
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
/**
|
||||
* @alpha
|
||||
* @deprecated The `featureDiscoveryServiceFactory` is deprecated in favor of using {@link @backstage/backend-defaults#discoveryFeatureLoader} instead.
|
||||
*/
|
||||
export const featureDiscoveryServiceFactory = createServiceFactory({
|
||||
service: featureDiscoveryServiceRef,
|
||||
deps: {
|
||||
@@ -164,21 +36,3 @@ export const featureDiscoveryServiceFactory = createServiceFactory({
|
||||
return new PackageDiscoveryService(config, logger);
|
||||
},
|
||||
});
|
||||
|
||||
function isBackendFeature(value: unknown): value is BackendFeature {
|
||||
return (
|
||||
!!value &&
|
||||
['object', 'function'].includes(typeof value) &&
|
||||
(value as BackendFeature).$$type === '@backstage/BackendFeature'
|
||||
);
|
||||
}
|
||||
|
||||
function isBackendFeatureFactory(
|
||||
value: unknown,
|
||||
): value is () => BackendFeature {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'function' &&
|
||||
(value as any).$$type === '@backstage/BackendFeatureFactory'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -577,6 +577,56 @@ describe('BackendInitializer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject plugins with missing dependencies', async () => {
|
||||
const init = new BackendInitializer(baseFactories);
|
||||
const ref = createServiceRef<string>({ id: 'a' });
|
||||
init.add(
|
||||
createBackendPlugin({
|
||||
pluginId: 'test',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { ref },
|
||||
async init() {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).rejects.toThrow(
|
||||
"Service or extension point dependencies of plugin 'test' are missing for the following ref(s): serviceRef{a}",
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject modules with missing dependencies', async () => {
|
||||
const init = new BackendInitializer(baseFactories);
|
||||
const ref = createServiceRef<string>({ id: 'a' });
|
||||
init.add(
|
||||
createBackendPlugin({
|
||||
pluginId: 'test',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {},
|
||||
async init() {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
init.add(
|
||||
createBackendModule({
|
||||
pluginId: 'test',
|
||||
moduleId: 'test-mod',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { ref },
|
||||
async init() {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(init.start()).rejects.toThrow(
|
||||
"Service or extension point dependencies of module 'test-mod' for plugin 'test' are missing for the following ref(s): serviceRef{a}",
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly load double-default CJS modules', async () => {
|
||||
expect.assertions(3);
|
||||
|
||||
|
||||
@@ -93,8 +93,11 @@ export class BackendInitializer {
|
||||
|
||||
if (missingRefs.size > 0) {
|
||||
const missing = Array.from(missingRefs).join(', ');
|
||||
const target = moduleId
|
||||
? `module '${moduleId}' for plugin '${pluginId}'`
|
||||
: `plugin '${pluginId}'`;
|
||||
throw new Error(
|
||||
`No extension point or service available for the following ref(s): ${missing}`,
|
||||
`Service or extension point dependencies of ${target} are missing for the following ref(s): ${missing}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +160,7 @@ export class BackendInitializer {
|
||||
}
|
||||
|
||||
const featureDiscovery = await this.#serviceRegistry.get(
|
||||
// TODO: Let's leave this in place and remove it once the deprecated service is removed. We can do that post-1.0 since it's alpha
|
||||
featureDiscoveryServiceRef,
|
||||
'root',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# @backstage/backend-common
|
||||
|
||||
## 0.25.0-next.1
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a4bac3c: **BREAKING**: You can no longer supply a `basePath` option to the host discovery implementation. In the new backend system, the ability to choose this path has been removed anyway at the plugin router level.
|
||||
- 988c145: **BREAKING**: Simplifications and cleanup as part of the Backend System 1.0 work.
|
||||
|
||||
- The deprecated `dropDatabase` function has now been removed, without replacement.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/backend-dev-utils@0.1.5
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/integration-aws-node@0.1.12
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.25.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d425fc4: **BREAKING**: The return values from `createBackendPlugin`, `createBackendModule`, and `createServiceFactory` are now simply `BackendFeature` and `ServiceFactory`, instead of the previously deprecated form of a function that returns them. For this reason, `createServiceFactory` also no longer accepts the callback form where you provide direct options to the service. This also affects all `coreServices.*` service refs.
|
||||
|
||||
This may in particular affect tests; if you were effectively doing `createBackendModule({...})()` (note the parentheses), you can now remove those extra parentheses at the end. You may encounter cases of this in your `packages/backend/src/index.ts` too, where you add plugins, modules, and services. If you were using `createServiceFactory` with a function as its argument for the purpose of passing in options, this pattern has been deprecated for a while and is no longer supported. You may want to explore the new multiton patterns to achieve your goals, or moving settings to app-config.
|
||||
|
||||
As part of this change, the `IdentityFactoryOptions` type was removed, and can no longer be used to tweak that service. The identity service was also deprecated some time ago, and you will want to [migrate to the new auth system](https://backstage.io/docs/tutorials/auth-service-migration) if you still rely on it.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8ba77ed: The `legacyPlugin` and `makeLegacyPlugin` helpers now provide their own shim implementation of the identity and token manager services, as these services are being removed from the new backend system.
|
||||
- d425fc4: Modules, plugins, and services are now `BackendFeature`, not a function that returns a feature.
|
||||
- 2e9ec14: Add `pg-format` as a dependency
|
||||
- 19ff127: Internal refactor to re-declare the token manager service which was removed from `@backstage/backend-plugin-api`, but is still supported in this package for backwards compatibility.
|
||||
- 66dbf0a: Allow the cache service to accept the human duration format for TTL
|
||||
- 0b2a402: Updates to the config schema to match reality
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/backend-dev-utils@0.1.5
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/integration-aws-node@0.1.12
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -65,10 +65,15 @@ export type CacheClientOptions = CacheServiceOptions;
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClientSetOptions = CacheServiceSetOptions;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "CacheManager_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export class CacheManager extends CacheManager_2 {}
|
||||
export class CacheManager {
|
||||
// (undocumented)
|
||||
forPlugin(pluginId: string): PluginCacheManager;
|
||||
static fromConfig(
|
||||
config: RootConfigService,
|
||||
options?: CacheManagerOptions,
|
||||
): CacheManager;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "CacheManagerOptions_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
@@ -157,7 +162,10 @@ export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: DatabaseManagerOptions,
|
||||
options?: {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): DatabaseManager;
|
||||
}
|
||||
|
||||
@@ -173,11 +181,6 @@ export class DockerContainerRunner implements ContainerRunner {
|
||||
runContainer(options: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "dropDatabase_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export const dropDatabase: typeof dropDatabase_2;
|
||||
|
||||
// @public @deprecated
|
||||
export function errorHandler(
|
||||
options?: ErrorHandlerOptions,
|
||||
@@ -274,12 +277,7 @@ export class Git {
|
||||
|
||||
// @public @deprecated
|
||||
class HostDiscovery implements DiscoveryService {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
basePath?: string;
|
||||
},
|
||||
): HostDiscovery;
|
||||
static fromConfig(config: Config): HostDiscovery;
|
||||
// (undocumented)
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
// (undocumented)
|
||||
@@ -367,10 +365,10 @@ export const legacyPlugin: (
|
||||
}>,
|
||||
) => BackendFeature;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "LegacyRootDatabaseService_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export type LegacyRootDatabaseService = LegacyRootDatabaseService_2;
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export function loadBackendConfig(options: {
|
||||
@@ -413,10 +411,10 @@ export function makeLegacyPlugin<
|
||||
// @public @deprecated
|
||||
export function notFoundHandler(): RequestHandler;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "PluginCacheManager_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginCacheManager = PluginCacheManager_2;
|
||||
export type PluginCacheManager = {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginDatabaseManager = DatabaseService;
|
||||
|
||||
Vendored
+18
-13
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
export interface Config {
|
||||
app: {
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
@@ -180,7 +182,7 @@ export interface Config {
|
||||
| {
|
||||
store: 'memory';
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number;
|
||||
defaultTtl?: number | HumanDuration;
|
||||
}
|
||||
| {
|
||||
store: 'redis';
|
||||
@@ -190,7 +192,7 @@ export interface Config {
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number;
|
||||
defaultTtl?: number | HumanDuration;
|
||||
/**
|
||||
* Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache.
|
||||
* Defaults to true if unspecified.
|
||||
@@ -205,9 +207,12 @@ export interface Config {
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number;
|
||||
defaultTtl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Properties returned upon CORS requests to the backend, including the app-backend.
|
||||
*/
|
||||
cors?: {
|
||||
origin?: string | string[];
|
||||
methods?: string | string[];
|
||||
@@ -219,6 +224,16 @@ export interface Config {
|
||||
optionsSuccessStatus?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content Security Policy options.
|
||||
*
|
||||
* The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The
|
||||
* values are on the format that the helmet library expects them, as an
|
||||
* array of strings. There is also the special value false, which means to
|
||||
* remove the default value that Backstage puts in place for that policy.
|
||||
*/
|
||||
csp?: { [policyId: string]: string[] | false };
|
||||
|
||||
/**
|
||||
* Configuration related to URL reading, used for example for reading catalog info
|
||||
* files, scaffolder templates, and techdocs content.
|
||||
@@ -246,15 +261,5 @@ export interface Config {
|
||||
paths?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content Security Policy options.
|
||||
*
|
||||
* The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The
|
||||
* values are on the format that the helmet library expects them, as an
|
||||
* array of strings. There is also the special value false, which means to
|
||||
* remove the default value that Backstage puts in place for that policy.
|
||||
*/
|
||||
csp?: { [policyId: string]: string[] | false };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"version": "0.24.0",
|
||||
"version": "0.25.0-next.1",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -107,6 +107,7 @@
|
||||
"p-limit": "^3.1.0",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"raw-body": "^2.4.1",
|
||||
"selfsigned": "^2.0.0",
|
||||
"stoppable": "^1.1.0",
|
||||
|
||||
@@ -23,17 +23,12 @@ import { HostDiscovery as _HostDiscovery } from '../../../backend-defaults/src/e
|
||||
import { CacheManager as _CacheManager } from '../../../backend-defaults/src/entrypoints/cache/CacheManager';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
type PluginCacheManager as _PluginCacheManager,
|
||||
type CacheManagerOptions as _CacheManagerOptions,
|
||||
} from '../../../backend-defaults/src/entrypoints/cache/types';
|
||||
import { type CacheManagerOptions as _CacheManagerOptions } from '../../../backend-defaults/src/entrypoints/cache/types';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
dropDatabase as _dropDatabase,
|
||||
DatabaseManager as _DatabaseManager,
|
||||
type DatabaseManagerOptions as _DatabaseManagerOptions,
|
||||
type LegacyRootDatabaseService as _LegacyRootDatabaseService,
|
||||
} from '../../../backend-defaults/src/entrypoints/database/DatabaseManager';
|
||||
|
||||
import {
|
||||
@@ -48,6 +43,9 @@ import {
|
||||
isChildPath as _isChildPath,
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
DatabaseService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
export * from './hot';
|
||||
@@ -98,11 +96,11 @@ export class HostDiscovery implements DiscoveryService {
|
||||
* plugins: [search]
|
||||
* ```
|
||||
*
|
||||
* The basePath defaults to `/api`, meaning the default full internal
|
||||
* The fixed base path is `/api`, meaning the default full internal
|
||||
* path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
|
||||
*/
|
||||
static fromConfig(config: Config, options?: { basePath?: string }) {
|
||||
return new HostDiscovery(_HostDiscovery.fromConfig(config, options));
|
||||
static fromConfig(config: Config) {
|
||||
return new HostDiscovery(_HostDiscovery.fromConfig(config));
|
||||
}
|
||||
|
||||
private constructor(private readonly impl: _HostDiscovery) {}
|
||||
@@ -133,7 +131,31 @@ export { HostDiscovery as SingleHostDiscovery };
|
||||
* @public
|
||||
* @deprecated Use `CacheManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export class CacheManager extends _CacheManager {}
|
||||
export class CacheManager {
|
||||
/**
|
||||
* Creates a new {@link CacheManager} instance by reading from the `backend`
|
||||
* config section, specifically the `.cache` key.
|
||||
*
|
||||
* @param config - The loaded application configuration.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: RootConfigService,
|
||||
options: CacheManagerOptions = {},
|
||||
): CacheManager {
|
||||
return new CacheManager(_CacheManager.fromConfig(config, options));
|
||||
}
|
||||
|
||||
private constructor(private readonly _impl: _CacheManager) {}
|
||||
|
||||
forPlugin(pluginId: string): PluginCacheManager {
|
||||
return {
|
||||
getClient: options => {
|
||||
const result = this._impl.forPlugin(pluginId);
|
||||
return options ? result.withOptions(options) : result;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -145,7 +167,9 @@ export type CacheManagerOptions = _CacheManagerOptions;
|
||||
* @public
|
||||
* @deprecated Use `PluginCacheManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export type PluginCacheManager = _PluginCacheManager;
|
||||
export type PluginCacheManager = {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -170,14 +194,20 @@ export type CacheClientOptions = CacheServiceOptions;
|
||||
* @deprecated Use `DatabaseManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
private constructor(private readonly _databaseManager: _DatabaseManager) {}
|
||||
private constructor(
|
||||
private readonly _databaseManager: _DatabaseManager,
|
||||
private readonly logger?: LoggerService,
|
||||
) {}
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: DatabaseManagerOptions,
|
||||
options?: {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): DatabaseManager {
|
||||
const _databaseManager = _DatabaseManager.fromConfig(config, options);
|
||||
return new DatabaseManager(_databaseManager);
|
||||
return new DatabaseManager(_databaseManager, options?.logger);
|
||||
}
|
||||
|
||||
forPlugin(
|
||||
@@ -186,7 +216,20 @@ export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
| { lifecycle: LifecycleService; pluginMetadata: PluginMetadataService }
|
||||
| undefined,
|
||||
): PluginDatabaseManager {
|
||||
return this._databaseManager.forPlugin(pluginId, deps);
|
||||
const logger: LoggerService = this.logger ?? {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
child() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const lifecycle: LifecycleService = deps?.lifecycle ?? {
|
||||
addShutdownHook() {},
|
||||
addStartupHook() {},
|
||||
};
|
||||
return this._databaseManager.forPlugin(pluginId, { logger, lifecycle });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,15 +247,11 @@ export type PluginDatabaseManager = _PluginDatabaseManager;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `LegacyRootDatabaseService` from the `@backstage/backend-defaults` package instead
|
||||
* @deprecated Use `DatabaseManager` from `@backstage/backend-defaults/database` instead, or migrate to the new backend system and use `coreServices.database`
|
||||
*/
|
||||
export type LegacyRootDatabaseService = _LegacyRootDatabaseService;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `dropDatabase` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export const dropDatabase = _dropDatabase;
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
||||
@@ -64,11 +64,19 @@ export class DockerContainerRunner implements ContainerRunner {
|
||||
if (pullImage) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.dockerClient.pull(imageName, pullOptions, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
stream.pipe(logStream, { end: false });
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (error: Error) => reject(error));
|
||||
return undefined;
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (!stream) {
|
||||
reject(
|
||||
new Error(
|
||||
'Unexpeected error: no stream returned from Docker while pulling image',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
stream.pipe(logStream, { end: false });
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (error: Error) => reject(error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -99,7 +107,7 @@ export class DockerContainerRunner implements ContainerRunner {
|
||||
}
|
||||
|
||||
// Create docker environment variables array
|
||||
const Env = [];
|
||||
const Env = new Array<string>();
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
Env.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,158 @@
|
||||
# @backstage/backend-defaults
|
||||
|
||||
## 0.5.0-next.1
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a4bac3c: **BREAKING**: You can no longer supply a `basePath` option to the host discovery implementation. In the new backend system, the ability to choose this path has been removed anyway at the plugin router level.
|
||||
- 055b75b: **BREAKING**: Simplifications and cleanup as part of the Backend System 1.0 work.
|
||||
|
||||
For the `/database` subpath exports:
|
||||
|
||||
- The deprecated `dropDatabase` function has now been removed, without replacement.
|
||||
- The deprecated `LegacyRootDatabaseService` type has now been removed.
|
||||
- The return type from `DatabaseManager.forPlugin` is now directly a `DatabaseService`, as arguably expected.
|
||||
- `DatabaseManager.forPlugin` now requires the `deps` argument, with the logger and lifecycle services.
|
||||
|
||||
For the `/cache` subpath exports:
|
||||
|
||||
- The `PluginCacheManager` type has been removed. You can still import it from `@backstage/backend-common`, but it's deprecated there, and you should move off of that package by migrating fully to the new backend system.
|
||||
- Accordingly, `CacheManager.forPlugin` immediately returns a `CacheService` instead of a `PluginCacheManager`. The outcome of this is that you no longer need to make the extra `.getClient()` call. The old `CacheManager` with the old behavior still exists on `@backstage/backend-common`, but the above recommendations apply.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 622360e: Move down the discovery config to be in the root
|
||||
- fe6fd8c: Accept `ConfigService` instead of `Config` in constructors/factories
|
||||
- 5705424: Wrap scheduled tasks from the scheduler core service now in OpenTelemetry spans
|
||||
- b2a329d: Properly indent the config schema
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.25.0-next.1
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/backend-app-api@0.10.0-next.1
|
||||
- @backstage/backend-dev-utils@0.1.5
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/integration-aws-node@0.1.12
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-events-node@0.4.0-next.1
|
||||
- @backstage/plugin-permission-node@0.8.3-next.1
|
||||
|
||||
## 0.5.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 359fcd7: **BREAKING**: The backwards compatibility with plugins using legacy auth through the token manager service has been removed. This means that instead of falling back to using the old token manager, requests towards plugins that don't support the new auth system will simply fail. Please make sure that all plugins in your deployment are hosted within a backend instance from the new backend system.
|
||||
- d425fc4: **BREAKING**: The return values from `createBackendPlugin`, `createBackendModule`, and `createServiceFactory` are now simply `BackendFeature` and `ServiceFactory`, instead of the previously deprecated form of a function that returns them. For this reason, `createServiceFactory` also no longer accepts the callback form where you provide direct options to the service. This also affects all `coreServices.*` service refs.
|
||||
|
||||
This may in particular affect tests; if you were effectively doing `createBackendModule({...})()` (note the parentheses), you can now remove those extra parentheses at the end. You may encounter cases of this in your `packages/backend/src/index.ts` too, where you add plugins, modules, and services. If you were using `createServiceFactory` with a function as its argument for the purpose of passing in options, this pattern has been deprecated for a while and is no longer supported. You may want to explore the new multiton patterns to achieve your goals, or moving settings to app-config.
|
||||
|
||||
As part of this change, the `IdentityFactoryOptions` type was removed, and can no longer be used to tweak that service. The identity service was also deprecated some time ago, and you will want to [migrate to the new auth system](https://backstage.io/docs/tutorials/auth-service-migration) if you still rely on it.
|
||||
|
||||
- 19ff127: **BREAKING**: The default backend instance no longer provides implementations for the identity and token manager services, which have been removed from `@backstage/backend-plugin-api`.
|
||||
|
||||
If you rely on plugins that still require these services, you can add them to your own backend by re-creating the service reference and factory.
|
||||
|
||||
The following can be used to implement the identity service:
|
||||
|
||||
```ts
|
||||
import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
createServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
DefaultIdentityClient,
|
||||
IdentityApi,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
backend.add(
|
||||
createServiceFactory({
|
||||
service: createServiceRef<IdentityApi>({ id: 'core.identity' }),
|
||||
deps: {
|
||||
discovery: coreServices.discovery,
|
||||
},
|
||||
async factory({ discovery }) {
|
||||
return DefaultIdentityClient.create({ discovery });
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
The following can be used to implement the token manager service:
|
||||
|
||||
```ts
|
||||
import { ServerTokenManager, TokenManager } from '@backstage/backend-common';
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
createServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
backend.add(
|
||||
createServiceFactory({
|
||||
service: createServiceRef<TokenManager>({ id: 'core.tokenManager' }),
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
logger: coreServices.rootLogger,
|
||||
},
|
||||
createRootContext({ config, logger }) {
|
||||
return ServerTokenManager.fromConfig(config, {
|
||||
logger,
|
||||
allowDisabledTokenManager: true,
|
||||
});
|
||||
},
|
||||
async factory(_deps, tokenManager) {
|
||||
return tokenManager;
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7f779c7: `auth.externalAccess` should be optional in the config schema
|
||||
- 7a72ec8: Exports the `discoveryFeatureLoader` as a replacement for the deprecated `featureDiscoveryService`.
|
||||
The `discoveryFeatureLoader` is a new backend system [feature loader](https://backstage.io/docs/backend-system/architecture/feature-loaders/) that discovers backend features from the current `package.json` and its dependencies.
|
||||
Here is an example using the `discoveryFeatureLoader` loader in a new backend instance:
|
||||
|
||||
```ts
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
import { discoveryFeatureLoader } from '@backstage/backend-defaults';
|
||||
//...
|
||||
|
||||
const backend = createBackend();
|
||||
//...
|
||||
backend.add(discoveryFeatureLoader);
|
||||
//...
|
||||
backend.start();
|
||||
```
|
||||
|
||||
- 66dbf0a: Allow the cache service to accept the human duration format for TTL
|
||||
- 5a8fcb4: Added the option to skip database migrations by setting `skipMigrations: true` in config. This can be done globally in the database config or by plugin id.
|
||||
- 0b2a402: Updates to the config schema to match reality
|
||||
- Updated dependencies
|
||||
- @backstage/backend-app-api@0.10.0-next.0
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/plugin-permission-node@0.8.3-next.0
|
||||
- @backstage/backend-common@0.25.0-next.0
|
||||
- @backstage/plugin-events-node@0.4.0-next.0
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/backend-dev-utils@0.1.5
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/integration-aws-node@0.1.12
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
|
||||
```ts
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceOptions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
export class CacheManager {
|
||||
forPlugin(pluginId: string): PluginCacheManager;
|
||||
forPlugin(pluginId: string): CacheService;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
config: RootConfigService,
|
||||
options?: CacheManagerOptions,
|
||||
): CacheManager;
|
||||
}
|
||||
@@ -31,11 +30,5 @@ export const cacheServiceFactory: ServiceFactory<
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface PluginCacheManager {
|
||||
// (undocumented)
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -3,24 +3,23 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { PluginMetadataService } from '@backstage/backend-plugin-api';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
export class DatabaseManager {
|
||||
forPlugin(
|
||||
pluginId: string,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): DatabaseService;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
config: RootConfigService,
|
||||
options?: DatabaseManagerOptions,
|
||||
): DatabaseManager;
|
||||
}
|
||||
@@ -28,7 +27,6 @@ export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
// @public
|
||||
export type DatabaseManagerOptions = {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -38,16 +36,5 @@ export const databaseServiceFactory: ServiceFactory<
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @public @deprecated
|
||||
export function dropDatabase(
|
||||
dbConfig: Config,
|
||||
...databaseNames: string[]
|
||||
): Promise<void>;
|
||||
|
||||
// @public
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryService } from '@backstage/backend-plugin-api';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
@@ -16,12 +16,7 @@ export const discoveryServiceFactory: ServiceFactory<
|
||||
|
||||
// @public
|
||||
export class HostDiscovery implements DiscoveryService {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
basePath?: string;
|
||||
},
|
||||
): HostDiscovery;
|
||||
static fromConfig(config: RootConfigService): HostDiscovery;
|
||||
// (undocumented)
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
// (undocumented)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
import { GiteaIntegration } from '@backstage/integration';
|
||||
import { GithubCredentialsProvider } from '@backstage/integration';
|
||||
@@ -21,6 +20,7 @@ import { GitLabIntegration } from '@backstage/integration';
|
||||
import { HarnessIntegration } from '@backstage/integration';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Readable } from 'stream';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { UrlReaderService } from '@backstage/backend-plugin-api';
|
||||
@@ -360,7 +360,7 @@ export class HarnessUrlReader implements UrlReaderService {
|
||||
|
||||
// @public
|
||||
export type ReaderFactory = (options: {
|
||||
config: Config;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
}) => UrlReaderPredicateTuple[];
|
||||
@@ -442,7 +442,7 @@ export const urlReaderServiceFactory: ServiceFactory<
|
||||
|
||||
// @public
|
||||
export type UrlReadersOptions = {
|
||||
config: Config;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
factories?: ReaderFactory[];
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
```ts
|
||||
import { Backend } from '@backstage/backend-app-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export function createBackend(): Backend;
|
||||
|
||||
// @public
|
||||
export const discoveryFeatureLoader: BackendFeature;
|
||||
```
|
||||
|
||||
+221
@@ -14,8 +14,51 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
export interface Config {
|
||||
app: {
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
};
|
||||
|
||||
backend?: {
|
||||
/**
|
||||
* The full base URL of the backend, as seen from the browser's point of
|
||||
* view as it makes calls to the backend.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/** Address that the backend should listen to. */
|
||||
listen?:
|
||||
| string
|
||||
| {
|
||||
/** Address of the interface that the backend should bind to. */
|
||||
host?: string;
|
||||
/** Port that the backend should listen to. */
|
||||
port?: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTPS configuration for the backend. If omitted the backend will serve HTTP.
|
||||
*
|
||||
* Setting this to `true` will cause self-signed certificates to be generated, which
|
||||
* can be useful for local development or other non-production scenarios.
|
||||
*/
|
||||
https?:
|
||||
| true
|
||||
| {
|
||||
/** Certificate configuration */
|
||||
certificate?: {
|
||||
/** PEM encoded certificate. Use $file to load in a file */
|
||||
cert: string;
|
||||
/**
|
||||
* PEM encoded certificate key. Use $file to load in a file.
|
||||
* @visibility secret
|
||||
*/
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options used by the default auth, httpAuth and userInfo services.
|
||||
*/
|
||||
@@ -330,6 +373,184 @@ export interface Config {
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
/** Database connection configuration, select base database type using the `client` field */
|
||||
database: {
|
||||
/** Default database client to use */
|
||||
client: 'better-sqlite3' | 'sqlite3' | 'pg';
|
||||
/**
|
||||
* Base database connection string, or object with individual connection properties
|
||||
* @visibility secret
|
||||
*/
|
||||
connection:
|
||||
| string
|
||||
| {
|
||||
/**
|
||||
* Password that belongs to the client User
|
||||
* @visibility secret
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* Other connection settings
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Database name prefix override */
|
||||
prefix?: string;
|
||||
/**
|
||||
* Whether to ensure the given database exists by creating it if it does not.
|
||||
* Defaults to true if unspecified.
|
||||
*/
|
||||
ensureExists?: boolean;
|
||||
/**
|
||||
* Whether to ensure the given database schema exists by creating it if it does not.
|
||||
* Defaults to false if unspecified.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client when pluginDivisionMode: schema
|
||||
*/
|
||||
ensureSchemaExists?: boolean;
|
||||
/**
|
||||
* How plugins databases are managed/divided in the provided database instance.
|
||||
*
|
||||
* `database` -> Plugins are each given their own database to manage their schemas/tables.
|
||||
*
|
||||
* `schema` -> Plugins will be given their own schema (in the specified/default database)
|
||||
* to manage their tables.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client.
|
||||
*
|
||||
* @default database
|
||||
*/
|
||||
pluginDivisionMode?: 'database' | 'schema';
|
||||
/** Configures the ownership of newly created schemas in pg databases. */
|
||||
role?: string;
|
||||
/**
|
||||
* Arbitrary config object to pass to knex when initializing
|
||||
* (https://knexjs.org/#Installation-client). Most notable is the debug
|
||||
* and asyncStackTraces booleans
|
||||
*/
|
||||
knexConfig?: object;
|
||||
/** Skip running database migrations. */
|
||||
skipMigrations?: boolean;
|
||||
/** Plugin specific database configuration and client override */
|
||||
plugin?: {
|
||||
[pluginId: string]: {
|
||||
/** Database client override */
|
||||
client?: 'better-sqlite3' | 'sqlite3' | 'pg';
|
||||
/**
|
||||
* Database connection string or Knex object override
|
||||
* @visibility secret
|
||||
*/
|
||||
connection?: string | object;
|
||||
/**
|
||||
* Whether to ensure the given database exists by creating it if it does not.
|
||||
* Defaults to base config if unspecified.
|
||||
*/
|
||||
ensureExists?: boolean;
|
||||
/**
|
||||
* Whether to ensure the given database schema exists by creating it if it does not.
|
||||
* Defaults to false if unspecified.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client when pluginDivisionMode: schema
|
||||
*/
|
||||
ensureSchemaExists?: boolean;
|
||||
/**
|
||||
* Arbitrary config object to pass to knex when initializing
|
||||
* (https://knexjs.org/#Installation-client). Most notable is the
|
||||
* debug and asyncStackTraces booleans.
|
||||
*
|
||||
* This is merged recursively into the base knexConfig
|
||||
*/
|
||||
knexConfig?: object;
|
||||
/** Configures the ownership of newly created schemas in pg databases. */
|
||||
role?: string;
|
||||
/** Skip running database migrations. */
|
||||
skipMigrations?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Cache connection configuration, select cache type using the `store` field */
|
||||
cache?:
|
||||
| {
|
||||
store: 'memory';
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
}
|
||||
| {
|
||||
store: 'redis';
|
||||
/**
|
||||
* A redis connection string in the form `redis://user:pass@host:port`.
|
||||
* @visibility secret
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
/**
|
||||
* Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache.
|
||||
* Defaults to true if unspecified.
|
||||
*/
|
||||
useRedisSets?: boolean;
|
||||
}
|
||||
| {
|
||||
store: 'memcache';
|
||||
/**
|
||||
* A memcache connection string in the form `user:pass@host:port`.
|
||||
* @visibility secret
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
cors?: {
|
||||
origin?: string | string[];
|
||||
methods?: string | string[];
|
||||
allowedHeaders?: string | string[];
|
||||
exposedHeaders?: string | string[];
|
||||
credentials?: boolean;
|
||||
maxAge?: number;
|
||||
preflightContinue?: boolean;
|
||||
optionsSuccessStatus?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content Security Policy options.
|
||||
*
|
||||
* The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The
|
||||
* values are on the format that the helmet library expects them, as an
|
||||
* array of strings. There is also the special value false, which means to
|
||||
* remove the default value that Backstage puts in place for that policy.
|
||||
*/
|
||||
csp?: { [policyId: string]: string[] | false };
|
||||
|
||||
/**
|
||||
* Configuration related to URL reading, used for example for reading catalog info
|
||||
* files, scaffolder templates, and techdocs content.
|
||||
*/
|
||||
reading?: {
|
||||
/**
|
||||
* A list of targets to allow outgoing requests to. Users will be able to make
|
||||
* requests on behalf of the backend to the targets that are allowed by this list.
|
||||
*/
|
||||
allow?: Array<{
|
||||
/**
|
||||
* A host to allow outgoing requests to, being either a full host or
|
||||
* a subdomain wildcard pattern with a leading `*`. For example `example.com`
|
||||
* and `*.example.com` are valid values, `prod.*.example.com` is not.
|
||||
* The host may also contain a port, for example `example.com:8080`.
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* An optional list of paths. In case they are present only targets matching
|
||||
* any of them will are allowed. You can use trailing slashes to make sure only
|
||||
* subdirectories are allowed, for example `/mydir/` will allow targets with
|
||||
* paths like `/mydir/a` but will block paths like `/mydir2`.
|
||||
*/
|
||||
paths?: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-defaults",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0-next.1",
|
||||
"description": "Backend defaults used by Backstage backend apps",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -124,6 +124,7 @@
|
||||
"@backstage/backend-dev-utils": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/config-loader": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
|
||||
import {
|
||||
BackendFeature,
|
||||
RootConfigService,
|
||||
RootLoggerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
|
||||
const DETECTED_PACKAGE_ROLES = [
|
||||
'node-library',
|
||||
'backend',
|
||||
'backend-plugin',
|
||||
'backend-plugin-module',
|
||||
];
|
||||
|
||||
/** @internal */
|
||||
function isBackendFeature(value: unknown): value is BackendFeature {
|
||||
return (
|
||||
!!value &&
|
||||
['object', 'function'].includes(typeof value) &&
|
||||
(value as BackendFeature).$$type === '@backstage/BackendFeature'
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function isBackendFeatureFactory(
|
||||
value: unknown,
|
||||
): value is () => BackendFeature {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'function' &&
|
||||
(value as any).$$type === '@backstage/BackendFeatureFactory'
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async function findClosestPackageDir(
|
||||
searchDir: string,
|
||||
): Promise<string | undefined> {
|
||||
let path = searchDir;
|
||||
|
||||
// Some confidence check to avoid infinite loop
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const packagePath = resolvePath(path, 'package.json');
|
||||
const exists = await fs.pathExists(packagePath);
|
||||
if (exists) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const newPath = dirname(path);
|
||||
if (newPath === path) {
|
||||
return undefined;
|
||||
}
|
||||
path = newPath;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Iteration limit reached when searching for root package.json at ${searchDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class PackageDiscoveryService implements FeatureDiscoveryService {
|
||||
constructor(
|
||||
private readonly config: RootConfigService,
|
||||
private readonly logger: RootLoggerService,
|
||||
) {}
|
||||
|
||||
getDependencyNames(path: string) {
|
||||
const { dependencies } = require(path) as BackstagePackageJson;
|
||||
const packagesConfig = this.config.getOptional('backend.packages');
|
||||
|
||||
const dependencyNames = Object.keys(dependencies || {});
|
||||
|
||||
if (packagesConfig === 'all') {
|
||||
return dependencyNames;
|
||||
}
|
||||
|
||||
const includedPackagesConfig = this.config.getOptionalStringArray(
|
||||
'backend.packages.include',
|
||||
);
|
||||
|
||||
const includedPackages = includedPackagesConfig
|
||||
? new Set(includedPackagesConfig)
|
||||
: dependencyNames;
|
||||
const excludedPackagesSet = new Set(
|
||||
this.config.getOptionalStringArray('backend.packages.exclude'),
|
||||
);
|
||||
|
||||
return [...includedPackages].filter(name => !excludedPackagesSet.has(name));
|
||||
}
|
||||
|
||||
async getBackendFeatures(): Promise<{ features: Array<BackendFeature> }> {
|
||||
const packagesConfig = this.config.getOptional('backend.packages');
|
||||
if (!packagesConfig || Object.keys(packagesConfig).length === 0) {
|
||||
return { features: [] };
|
||||
}
|
||||
|
||||
const packageDir = await findClosestPackageDir(process.argv[1]);
|
||||
if (!packageDir) {
|
||||
throw new Error('Package discovery failed to find package.json');
|
||||
}
|
||||
const dependencyNames = this.getDependencyNames(
|
||||
resolvePath(packageDir, 'package.json'),
|
||||
);
|
||||
|
||||
const features: BackendFeature[] = [];
|
||||
|
||||
for (const name of dependencyNames) {
|
||||
const depPkg = require(require.resolve(`${name}/package.json`, {
|
||||
paths: [packageDir],
|
||||
})) as BackstagePackageJson;
|
||||
if (
|
||||
!depPkg?.backstage?.role ||
|
||||
!DETECTED_PACKAGE_ROLES.includes(depPkg.backstage.role)
|
||||
) {
|
||||
continue; // Not a backstage backend package, ignore
|
||||
}
|
||||
|
||||
const exportedModulePaths = [
|
||||
require.resolve(name, {
|
||||
paths: [packageDir],
|
||||
}),
|
||||
];
|
||||
|
||||
// Find modules exported as alpha
|
||||
try {
|
||||
exportedModulePaths.push(
|
||||
require.resolve(`${name}/alpha`, { paths: [packageDir] }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
for (const modulePath of exportedModulePaths) {
|
||||
const mod = require(modulePath);
|
||||
|
||||
if (isBackendFeature(mod.default)) {
|
||||
this.logger.info(`Detected: ${name}`);
|
||||
features.push(mod.default);
|
||||
}
|
||||
if (isBackendFeatureFactory(mod.default)) {
|
||||
this.logger.info(`Detected: ${name}`);
|
||||
features.push(mod.default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { features };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
coreServices,
|
||||
createBackendFeatureLoader,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { PackageDiscoveryService } from './PackageDiscoveryService';
|
||||
|
||||
/**
|
||||
* A loader that discovers backend features from the current package.json and its dependencies.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* Using the `discoveryFeatureLoader` loader in a backend instance:
|
||||
* ```ts
|
||||
* //...
|
||||
* import { createBackend } from '@backstage/backend-defaults';
|
||||
* import { discoveryFeatureLoader } from '@backstage/backend-defaults';
|
||||
*
|
||||
* const backend = createBackend();
|
||||
* backend.add(discoveryFeatureLoader);
|
||||
* //...
|
||||
* backend.start();
|
||||
* ```
|
||||
*/
|
||||
export const discoveryFeatureLoader = createBackendFeatureLoader({
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
logger: coreServices.rootLogger,
|
||||
},
|
||||
async loader({ config, logger }) {
|
||||
const service = new PackageDiscoveryService(config, logger);
|
||||
const { features } = await service.getBackendFeatures();
|
||||
return features;
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { createHash } from 'crypto';
|
||||
import Keyv from 'keyv';
|
||||
import { ttlToMilliseconds } from './types';
|
||||
|
||||
export type CacheClientFactory = (options: CacheServiceOptions) => Keyv;
|
||||
|
||||
@@ -58,7 +59,9 @@ export class DefaultCacheClient implements CacheService {
|
||||
opts: CacheServiceSetOptions = {},
|
||||
): Promise<void> {
|
||||
const k = this.getNormalizedKey(key);
|
||||
await this.#client.set(k, value, opts.ttl);
|
||||
const ttl =
|
||||
opts.ttl !== undefined ? ttlToMilliseconds(opts.ttl) : undefined;
|
||||
await this.#client.set(k, value, ttl);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
|
||||
@@ -52,10 +52,10 @@ describe('CacheManager integration', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
manager.forPlugin('p1').getClient();
|
||||
manager.forPlugin('p1').getClient({ defaultTtl: 200 });
|
||||
manager.forPlugin('p2').getClient();
|
||||
manager.forPlugin('p3').getClient({});
|
||||
manager.forPlugin('p1');
|
||||
manager.forPlugin('p1').withOptions({ defaultTtl: 200 });
|
||||
manager.forPlugin('p2');
|
||||
manager.forPlugin('p3').withOptions({});
|
||||
|
||||
if (store === 'redis') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
@@ -80,9 +80,11 @@ describe('CacheManager integration', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const plugin1 = manager.forPlugin('p1').getClient();
|
||||
const plugin2a = manager.forPlugin('p2').getClient();
|
||||
const plugin2b = manager.forPlugin('p2').getClient({ defaultTtl: 2000 });
|
||||
const plugin1 = manager.forPlugin('p1');
|
||||
const plugin2a = manager.forPlugin('p2');
|
||||
const plugin2b = manager
|
||||
.forPlugin('p2')
|
||||
.withOptions({ defaultTtl: 2000 });
|
||||
|
||||
await plugin1.set('a', 'plugin1');
|
||||
await plugin2a.set('a', 'plugin2a');
|
||||
@@ -93,4 +95,96 @@ describe('CacheManager integration', () => {
|
||||
await expect(plugin2b.get('a')).resolves.toBe('plugin2b');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'supports both milliseconds and human durations throughout, %p',
|
||||
async cacheId => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
for (const defaultTtl of [200, { milliseconds: 200 }]) {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store,
|
||||
connection,
|
||||
defaultTtl,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).forPlugin('p');
|
||||
|
||||
const defaultClient = manager;
|
||||
const numberOverrideClient = manager.withOptions({ defaultTtl: 400 });
|
||||
const durationOverrideClient = manager.withOptions({
|
||||
defaultTtl: { milliseconds: 400 },
|
||||
});
|
||||
|
||||
await defaultClient.set('a', 'x');
|
||||
await defaultClient.set('b', 'x');
|
||||
await numberOverrideClient.set('c', 'x');
|
||||
await durationOverrideClient.set('d', 'x');
|
||||
await defaultClient.set('e', 'x', { ttl: 400 });
|
||||
await defaultClient.set('f', 'x', { ttl: { milliseconds: 400 } });
|
||||
|
||||
await expect(defaultClient.get('a')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('b')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('c')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('d')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('e')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('f')).resolves.toBe('x');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50 + 200));
|
||||
|
||||
await expect(defaultClient.get('a')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('b')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('c')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('d')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('e')).resolves.toBe('x');
|
||||
await expect(defaultClient.get('f')).resolves.toBe('x');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await expect(defaultClient.get('a')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('b')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('c')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('d')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('e')).resolves.toBeUndefined();
|
||||
await expect(defaultClient.get('f')).resolves.toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects invalid defaultTtl', () => {
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
defaultTtl: 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/Invalid configuration backend.cache.defaultTtl/);
|
||||
});
|
||||
});
|
||||
|
||||
+32
-20
@@ -15,13 +15,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
CacheService,
|
||||
CacheServiceOptions,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import Keyv from 'keyv';
|
||||
import { DefaultCacheClient } from './CacheClient';
|
||||
import { CacheManagerOptions, PluginCacheManager } from './types';
|
||||
import { CacheManagerOptions, ttlToMilliseconds } from './types';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
|
||||
type StoreFactory = (pluginId: string, defaultTtl: number | undefined) => Keyv;
|
||||
|
||||
@@ -57,13 +59,13 @@ export class CacheManager {
|
||||
* @param config - The loaded application configuration.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
config: RootConfigService,
|
||||
options: CacheManagerOptions = {},
|
||||
): CacheManager {
|
||||
// If no `backend.cache` config is provided, instantiate the CacheManager
|
||||
// with an in-memory cache client.
|
||||
const store = config.getOptionalString('backend.cache.store') || 'memory';
|
||||
const defaultTtl = config.getOptionalNumber('backend.cache.defaultTtl');
|
||||
const defaultTtlConfig = config.getOptional('backend.cache.defaultTtl');
|
||||
const connectionString =
|
||||
config.getOptionalString('backend.cache.connection') || '';
|
||||
const useRedisSets =
|
||||
@@ -71,6 +73,23 @@ export class CacheManager {
|
||||
const logger = options.logger?.child({
|
||||
type: 'cacheManager',
|
||||
});
|
||||
|
||||
let defaultTtl: number | undefined;
|
||||
if (defaultTtlConfig !== undefined && defaultTtlConfig !== null) {
|
||||
if (typeof defaultTtlConfig === 'number') {
|
||||
defaultTtl = defaultTtlConfig;
|
||||
} else if (
|
||||
typeof defaultTtlConfig === 'object' &&
|
||||
!Array.isArray(defaultTtlConfig)
|
||||
) {
|
||||
defaultTtl = durationToMilliseconds(defaultTtlConfig);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid configuration backend.cache.defaultTtl: ${defaultTtlConfig}, expected milliseconds number or HumanDuration object`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new CacheManager(
|
||||
store,
|
||||
connectionString,
|
||||
@@ -107,23 +126,16 @@ export class CacheManager {
|
||||
* @param pluginId - The plugin that the cache manager should be created for.
|
||||
* Plugin names should be unique.
|
||||
*/
|
||||
forPlugin(pluginId: string): PluginCacheManager {
|
||||
return {
|
||||
getClient: (defaultOptions = {}) => {
|
||||
const clientFactory = (options: CacheServiceOptions) => {
|
||||
return this.getClientWithTtl(
|
||||
pluginId,
|
||||
options.defaultTtl ?? this.defaultTtl,
|
||||
);
|
||||
};
|
||||
|
||||
return new DefaultCacheClient(
|
||||
clientFactory(defaultOptions),
|
||||
clientFactory,
|
||||
defaultOptions,
|
||||
);
|
||||
},
|
||||
forPlugin(pluginId: string): CacheService {
|
||||
const clientFactory = (options: CacheServiceOptions) => {
|
||||
const ttl = options.defaultTtl ?? this.defaultTtl;
|
||||
return this.getClientWithTtl(
|
||||
pluginId,
|
||||
ttl !== undefined ? ttlToMilliseconds(ttl) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
return new DefaultCacheClient(clientFactory({}), clientFactory, {});
|
||||
}
|
||||
|
||||
private getClientWithTtl(pluginId: string, ttl: number | undefined): Keyv {
|
||||
|
||||
@@ -40,6 +40,6 @@ export const cacheServiceFactory = createServiceFactory({
|
||||
return CacheManager.fromConfig(config, { logger });
|
||||
},
|
||||
async factory({ plugin }, manager) {
|
||||
return manager.forPlugin(plugin.getId()).getClient();
|
||||
return manager.forPlugin(plugin.getId());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
|
||||
export { cacheServiceFactory } from './cacheServiceFactory';
|
||||
export { CacheManager } from './CacheManager';
|
||||
export type { CacheManagerOptions, PluginCacheManager } from './types';
|
||||
export type { CacheManagerOptions } from './types';
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
*/
|
||||
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
CacheService,
|
||||
CacheServiceOptions,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Options given when constructing a {@link CacheManager}.
|
||||
@@ -38,9 +35,6 @@ export type CacheManagerOptions = {
|
||||
onError?: (err: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface PluginCacheManager {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
export function ttlToMilliseconds(ttl: number | HumanDuration): number {
|
||||
return typeof ttl === 'number' ? ttl : durationToMilliseconds(ttl);
|
||||
}
|
||||
|
||||
@@ -17,20 +17,24 @@
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DatabaseManagerImpl } from './DatabaseManager';
|
||||
import { Connector } from './types';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('DatabaseManagerImpl', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const deps = {
|
||||
logger: mockServices.logger.mock(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
};
|
||||
|
||||
it('calls the right connector, only once per plugin id', async () => {
|
||||
const connector1 = {
|
||||
getClient: jest.fn(),
|
||||
dropDatabase: jest.fn(),
|
||||
} satisfies Connector;
|
||||
const connector2 = {
|
||||
getClient: jest.fn(),
|
||||
dropDatabase: jest.fn(),
|
||||
} satisfies Connector;
|
||||
|
||||
const impl = new DatabaseManagerImpl(
|
||||
@@ -43,30 +47,28 @@ describe('DatabaseManagerImpl', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await impl.forPlugin('plugin1').getClient();
|
||||
await impl.forPlugin('plugin1', deps).getClient();
|
||||
expect(connector1.getClient).toHaveBeenCalledTimes(1);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', undefined);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', deps);
|
||||
expect(connector2.getClient).toHaveBeenCalledTimes(0);
|
||||
|
||||
await impl.forPlugin('plugin1').getClient();
|
||||
await impl.forPlugin('plugin1', deps).getClient();
|
||||
expect(connector1.getClient).toHaveBeenCalledTimes(1);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', undefined);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', deps);
|
||||
expect(connector2.getClient).toHaveBeenCalledTimes(0);
|
||||
|
||||
await impl.forPlugin('plugin2').getClient();
|
||||
await impl.forPlugin('plugin2', deps).getClient();
|
||||
expect(connector1.getClient).toHaveBeenCalledTimes(2);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin2', undefined);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin2', deps);
|
||||
expect(connector2.getClient).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('respects per-plugin overridden connectors', async () => {
|
||||
const connector1 = {
|
||||
getClient: jest.fn(),
|
||||
dropDatabase: jest.fn(),
|
||||
} satisfies Connector;
|
||||
const connector2 = {
|
||||
getClient: jest.fn(),
|
||||
dropDatabase: jest.fn(),
|
||||
} satisfies Connector;
|
||||
|
||||
const impl = new DatabaseManagerImpl(
|
||||
@@ -84,39 +86,93 @@ describe('DatabaseManagerImpl', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await impl.forPlugin('plugin1').getClient();
|
||||
await impl.forPlugin('plugin1', deps).getClient();
|
||||
expect(connector1.getClient).toHaveBeenCalledTimes(1);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', undefined);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', deps);
|
||||
expect(connector2.getClient).toHaveBeenCalledTimes(0);
|
||||
|
||||
await impl.forPlugin('plugin2').getClient();
|
||||
await impl.forPlugin('plugin2', deps).getClient();
|
||||
expect(connector1.getClient).toHaveBeenCalledTimes(1);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', undefined);
|
||||
expect(connector1.getClient).toHaveBeenLastCalledWith('plugin1', deps);
|
||||
expect(connector2.getClient).toHaveBeenCalledTimes(1);
|
||||
expect(connector2.getClient).toHaveBeenLastCalledWith('plugin2', undefined);
|
||||
expect(connector2.getClient).toHaveBeenLastCalledWith('plugin2', deps);
|
||||
});
|
||||
|
||||
it('retains the migration skip info', async () => {
|
||||
it('migration skip options take precedence over config', async () => {
|
||||
const connector = {
|
||||
getClient: jest.fn(),
|
||||
dropDatabase: jest.fn(),
|
||||
} satisfies Connector;
|
||||
|
||||
const impl = new DatabaseManagerImpl(
|
||||
new ConfigReader({
|
||||
client: 'pg',
|
||||
backend: {
|
||||
database: {
|
||||
skipMigrations: true,
|
||||
plugin: { plugin1: { skipMigrations: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
pg: connector,
|
||||
},
|
||||
{ migrations: { skip: false } },
|
||||
);
|
||||
expect((await impl.forPlugin('plugin1', deps)).migrations).toEqual({
|
||||
skip: false,
|
||||
});
|
||||
|
||||
const impl1 = new DatabaseManagerImpl(new ConfigReader({ client: 'pg' }), {
|
||||
pg: connector,
|
||||
});
|
||||
|
||||
const impl2 = new DatabaseManagerImpl(
|
||||
new ConfigReader({ client: 'pg' }),
|
||||
{ pg: connector },
|
||||
{ migrations: { skip: true } },
|
||||
expect((await impl1.forPlugin('plugin1', deps)).migrations).toEqual({
|
||||
skip: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('plugin can skip migrations using config', async () => {
|
||||
const connector = {
|
||||
getClient: jest.fn(),
|
||||
} satisfies Connector;
|
||||
|
||||
const impl = new DatabaseManagerImpl(
|
||||
new ConfigReader({
|
||||
client: 'pg',
|
||||
backend: {
|
||||
database: { plugin: { plugin1: { skipMigrations: true } } },
|
||||
},
|
||||
}),
|
||||
{
|
||||
pg: connector,
|
||||
},
|
||||
);
|
||||
|
||||
expect((await impl1.forPlugin('plugin1')).migrations).toEqual({
|
||||
expect((await impl.forPlugin('plugin1', deps)).migrations).toEqual({
|
||||
skip: true,
|
||||
});
|
||||
expect((await impl.forPlugin('plugin2', deps)).migrations).toEqual({
|
||||
skip: false,
|
||||
});
|
||||
|
||||
expect((await impl2.forPlugin('plugin1')).migrations).toEqual({
|
||||
const impl2 = new DatabaseManagerImpl(
|
||||
new ConfigReader({
|
||||
client: 'pg',
|
||||
backend: {
|
||||
database: {
|
||||
skipMigrations: true,
|
||||
plugin: { plugin1: { skipMigrations: false } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
pg: connector,
|
||||
},
|
||||
);
|
||||
expect((await impl2.forPlugin('plugin1', deps)).migrations).toEqual({
|
||||
skip: false,
|
||||
});
|
||||
expect((await impl2.forPlugin('plugin2', deps)).migrations).toEqual({
|
||||
skip: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DatabaseService,
|
||||
LifecycleService,
|
||||
LoggerService,
|
||||
PluginMetadataService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { stringifyError } from '@backstage/errors';
|
||||
@@ -42,21 +42,12 @@ function pluginPath(pluginId: string): string {
|
||||
*/
|
||||
export type DatabaseManagerOptions = {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
/**
|
||||
* An interface that represents the legacy global DatabaseManager implementation.
|
||||
* @public
|
||||
*/
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Testable implementation class for {@link DatabaseManager} below.
|
||||
*/
|
||||
export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
export class DatabaseManagerImpl {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly connectors: Record<string, Connector>,
|
||||
@@ -73,9 +64,9 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
*/
|
||||
forPlugin(
|
||||
pluginId: string,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): PluginDatabaseManager {
|
||||
const client = this.getClientType(pluginId).client;
|
||||
@@ -86,8 +77,16 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
);
|
||||
}
|
||||
const getClient = () => this.getDatabase(pluginId, connector, deps);
|
||||
const migrations = { skip: false, ...this.options?.migrations };
|
||||
return { getClient, migrations };
|
||||
|
||||
const skip =
|
||||
this.options?.migrations?.skip ??
|
||||
this.config.getOptionalBoolean(
|
||||
`backend.database.plugin.${pluginId}.skipMigrations`,
|
||||
) ??
|
||||
this.config.getOptionalBoolean('backend.database.skipMigrations') ??
|
||||
false;
|
||||
|
||||
return { getClient, migrations: { skip } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,9 +126,9 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
private async getDatabase(
|
||||
pluginId: string,
|
||||
connector: Connector,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Promise<Knex> {
|
||||
if (this.databaseCache.has(pluginId)) {
|
||||
@@ -140,13 +139,19 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
this.databaseCache.set(pluginId, clientPromise);
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
clientPromise.then(client => this.startKeepaliveLoop(pluginId, client));
|
||||
clientPromise.then(client =>
|
||||
this.startKeepaliveLoop(pluginId, client, deps.logger),
|
||||
);
|
||||
}
|
||||
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
private startKeepaliveLoop(pluginId: string, client: Knex): void {
|
||||
private startKeepaliveLoop(
|
||||
pluginId: string,
|
||||
client: Knex,
|
||||
logger: LoggerService,
|
||||
): void {
|
||||
let lastKeepaliveFailed = false;
|
||||
|
||||
setInterval(() => {
|
||||
@@ -159,7 +164,7 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
(error: unknown) => {
|
||||
if (!lastKeepaliveFailed) {
|
||||
lastKeepaliveFailed = true;
|
||||
this.options?.logger?.warn(
|
||||
logger.warn(
|
||||
`Database keepalive failed for plugin ${pluginId}, ${stringifyError(
|
||||
error,
|
||||
)}`,
|
||||
@@ -184,7 +189,7 @@ export class DatabaseManagerImpl implements LegacyRootDatabaseService {
|
||||
* set `prefix` which is used to prefix generated database names if config is
|
||||
* not provided.
|
||||
*/
|
||||
export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
export class DatabaseManager {
|
||||
/**
|
||||
* Creates a {@link DatabaseManager} from `backend.database` config.
|
||||
*
|
||||
@@ -192,7 +197,7 @@ export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
* @param options - An optional configuration object.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
config: RootConfigService,
|
||||
options?: DatabaseManagerOptions,
|
||||
): DatabaseManager {
|
||||
const databaseConfig = config.getConfig('backend.database');
|
||||
@@ -224,31 +229,11 @@ export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
*/
|
||||
forPlugin(
|
||||
pluginId: string,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): PluginDatabaseManager {
|
||||
return this.impl.forPlugin(pluginId, deps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for deleting databases.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Will be removed in a future release.
|
||||
*/
|
||||
export async function dropDatabase(
|
||||
dbConfig: Config,
|
||||
...databaseNames: string[]
|
||||
): Promise<void> {
|
||||
const client = dbConfig.getString('client');
|
||||
const prefix = dbConfig.getOptionalString('prefix') || 'backstage_plugin_';
|
||||
|
||||
if (client === 'pg') {
|
||||
await new PgConnector(dbConfig, prefix).dropDatabase(...databaseNames);
|
||||
} else if (client === 'mysql' || client === 'mysql2') {
|
||||
await new MysqlConnector(dbConfig, prefix).dropDatabase(...databaseNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
@@ -253,9 +250,9 @@ export class MysqlConnector implements Connector {
|
||||
|
||||
async getClient(
|
||||
pluginId: string,
|
||||
_deps?: {
|
||||
_deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Promise<Knex> {
|
||||
const pluginConfig = new ConfigReader(
|
||||
@@ -293,10 +290,6 @@ export class MysqlConnector implements Connector {
|
||||
return client;
|
||||
}
|
||||
|
||||
async dropDatabase(...databaseNames: string[]): Promise<void> {
|
||||
return await dropMysqlDatabase(this.config, ...databaseNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the canonical database name for a given plugin.
|
||||
*
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
@@ -261,9 +258,9 @@ export class PgConnector implements Connector {
|
||||
|
||||
async getClient(
|
||||
pluginId: string,
|
||||
_deps?: {
|
||||
_deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Promise<Knex> {
|
||||
const pluginConfig = new ConfigReader(
|
||||
@@ -312,10 +309,6 @@ export class PgConnector implements Connector {
|
||||
return client;
|
||||
}
|
||||
|
||||
async dropDatabase(...databaseNames: string[]): Promise<void> {
|
||||
return await dropPgDatabase(this.config, ...databaseNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the canonical database name for a given plugin.
|
||||
*
|
||||
|
||||
@@ -20,11 +20,17 @@ import {
|
||||
buildSqliteDatabaseConfig,
|
||||
createSqliteDatabaseClient,
|
||||
} from './sqlite3';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('better-sqlite3', () => {
|
||||
const createConfig = (connection: any) =>
|
||||
new ConfigReader({ client: 'better-sqlite3', connection });
|
||||
|
||||
const deps = {
|
||||
logger: mockServices.logger.mock(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
};
|
||||
|
||||
describe('buildSqliteDatabaseConfig', () => {
|
||||
it('builds an in-memory connection', () => {
|
||||
expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({
|
||||
@@ -90,7 +96,9 @@ describe('better-sqlite3', () => {
|
||||
|
||||
describe('createSqliteDatabaseClient', () => {
|
||||
it('creates an in memory knex instance', () => {
|
||||
expect(createSqliteDatabaseClient(createConfig(':memory:'))).toBeTruthy();
|
||||
expect(
|
||||
createSqliteDatabaseClient('p', createConfig(':memory:'), deps),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
*/
|
||||
|
||||
import { DevDataStore } from '@backstage/backend-dev-utils';
|
||||
import {
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
@@ -35,12 +32,13 @@ import { mergeDatabaseConfig } from './mergeDatabaseConfig';
|
||||
* @param overrides - Additional options to merge with the config
|
||||
*/
|
||||
export function createSqliteDatabaseClient(
|
||||
pluginId: string,
|
||||
dbConfig: Config,
|
||||
overrides?: Knex.Config,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
overrides?: Knex.Config,
|
||||
) {
|
||||
const knexConfig = buildSqliteDatabaseConfig(dbConfig, overrides);
|
||||
const connConfig = knexConfig.connection as Knex.Sqlite3ConnectionConfig;
|
||||
@@ -62,7 +60,7 @@ export function createSqliteDatabaseClient(
|
||||
const devStore = DevDataStore.get();
|
||||
|
||||
if (devStore) {
|
||||
const dataKey = `sqlite3-db-${deps.pluginMetadata.getId()}`;
|
||||
const dataKey = `sqlite3-db-${pluginId}`;
|
||||
|
||||
const connectionLoader = async () => {
|
||||
// If seed data is available, use it tconnectionLoader restore the database
|
||||
@@ -180,9 +178,9 @@ export class Sqlite3Connector implements Connector {
|
||||
|
||||
async getClient(
|
||||
pluginId: string,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Promise<Knex> {
|
||||
const pluginConfig = new ConfigReader(
|
||||
@@ -202,18 +200,15 @@ export class Sqlite3Connector implements Connector {
|
||||
);
|
||||
|
||||
const client = createSqliteDatabaseClient(
|
||||
pluginId,
|
||||
pluginConfig,
|
||||
databaseClientOverrides,
|
||||
deps,
|
||||
databaseClientOverrides,
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async dropDatabase(..._databaseNames: string[]): Promise<void> {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the canonical database name for a given plugin.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,7 @@ export const databaseServiceFactory = createServiceFactory({
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
lifecycle: coreServices.lifecycle,
|
||||
logger: coreServices.logger,
|
||||
pluginMetadata: coreServices.pluginMetadata,
|
||||
},
|
||||
async createRootContext({ config }) {
|
||||
@@ -48,10 +49,10 @@ export const databaseServiceFactory = createServiceFactory({
|
||||
}),
|
||||
);
|
||||
},
|
||||
async factory({ pluginMetadata, lifecycle }, databaseManager) {
|
||||
async factory({ pluginMetadata, lifecycle, logger }, databaseManager) {
|
||||
return databaseManager.forPlugin(pluginMetadata.getId(), {
|
||||
pluginMetadata,
|
||||
lifecycle,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,4 @@ export { databaseServiceFactory } from './databaseServiceFactory';
|
||||
export {
|
||||
DatabaseManager,
|
||||
type DatabaseManagerOptions,
|
||||
type LegacyRootDatabaseService,
|
||||
dropDatabase,
|
||||
} from './DatabaseManager';
|
||||
|
||||
@@ -14,87 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export type { DatabaseService as PluginDatabaseManager } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Manages an underlying Knex database driver.
|
||||
*/
|
||||
export interface DatabaseConnector {
|
||||
/**
|
||||
* Provides an instance of a knex database connector.
|
||||
*/
|
||||
createClient(
|
||||
dbConfig: Config,
|
||||
overrides?: Partial<Knex.Config>,
|
||||
deps?: {
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Knex;
|
||||
|
||||
/**
|
||||
* Provides a partial knex config sufficient to override a database name.
|
||||
*/
|
||||
createNameOverride(name: string): Partial<Knex.Config>;
|
||||
|
||||
/**
|
||||
* Provides a partial knex config sufficient to override a PostgreSQL schema
|
||||
* name within utilizing the `searchPath` knex configuration.
|
||||
*/
|
||||
createSchemaOverride?(name: string): Partial<Knex.Config>;
|
||||
|
||||
/**
|
||||
* Produces a knex connection config object representing a database connection
|
||||
* string.
|
||||
*/
|
||||
parseConnectionString(
|
||||
connectionString: string,
|
||||
client?: string,
|
||||
): Knex.StaticConnectionConfig;
|
||||
|
||||
/**
|
||||
* Performs a side-effect to ensure database names passed in are present.
|
||||
*
|
||||
* Calling this function on databases which already exist should do nothing.
|
||||
* Missing databases should be created if needed.
|
||||
*/
|
||||
ensureDatabaseExists?(
|
||||
dbConfig: Config,
|
||||
...databases: Array<string>
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Performs a side-effect to ensure schema names passed in are present.
|
||||
*
|
||||
* Calling this function on schemas which already exist should do nothing.
|
||||
* Missing schemas should be created if needed.
|
||||
*/
|
||||
ensureSchemaExists?(
|
||||
dbConfig: Config,
|
||||
...schemas: Array<string>
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes databases.
|
||||
*/
|
||||
dropDatabase?(dbConfig: Config, ...databases: Array<string>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
getClient(
|
||||
pluginId: string,
|
||||
deps?: {
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
},
|
||||
): Promise<Knex>;
|
||||
|
||||
dropDatabase(...databaseNames: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -54,25 +54,6 @@ describe('HostDiscovery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('can configure the base path', async () => {
|
||||
const discovery = HostDiscovery.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
baseUrl: 'http://localhost:40',
|
||||
listen: { port: 80, host: 'localhost' },
|
||||
},
|
||||
}),
|
||||
{ basePath: '/service' },
|
||||
);
|
||||
|
||||
await expect(discovery.getBaseUrl('catalog')).resolves.toBe(
|
||||
'http://localhost:80/service/catalog',
|
||||
);
|
||||
await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe(
|
||||
'http://localhost:40/service/catalog',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ listen: ':80' }, 'http://localhost:80'],
|
||||
[{ listen: ':40', https: true }, 'https://localhost:40'],
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
DiscoveryService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { readHttpServerOptions } from '../rootHttpRouter/http/config';
|
||||
|
||||
type Target = string | { internal: string; external: string };
|
||||
@@ -39,6 +42,7 @@ export class HostDiscovery implements DiscoveryService {
|
||||
*
|
||||
* Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
|
||||
* eg.
|
||||
*
|
||||
* ```yaml
|
||||
* discovery:
|
||||
* endpoints:
|
||||
@@ -52,11 +56,11 @@ export class HostDiscovery implements DiscoveryService {
|
||||
* plugins: [search]
|
||||
* ```
|
||||
*
|
||||
* The basePath defaults to `/api`, meaning the default full internal
|
||||
* The fixed base path is `/api`, meaning the default full internal
|
||||
* path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
|
||||
*/
|
||||
static fromConfig(config: Config, options?: { basePath?: string }) {
|
||||
const basePath = options?.basePath ?? '/api';
|
||||
static fromConfig(config: RootConfigService) {
|
||||
const basePath = '/api';
|
||||
const externalBaseUrl = config
|
||||
.getString('backend.baseUrl')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
+6
-8
@@ -19,13 +19,11 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createCredentialsBarrier } from './createCredentialsBarrier';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
import { MiddlewareFactory } from '../rootHttpRouter/http';
|
||||
|
||||
const errorMiddleware = MiddlewareFactory.create({
|
||||
config: mockServices.rootConfig(),
|
||||
logger: mockServices.rootLogger(),
|
||||
}).error();
|
||||
import {
|
||||
mockCredentials,
|
||||
mockErrorHandler,
|
||||
mockServices,
|
||||
} from '@backstage/backend-test-utils';
|
||||
|
||||
function setup() {
|
||||
const barrier = createCredentialsBarrier({
|
||||
@@ -37,7 +35,7 @@ function setup() {
|
||||
|
||||
const app = express();
|
||||
app.use(barrier.middleware);
|
||||
app.use(errorMiddleware);
|
||||
app.use(mockErrorHandler());
|
||||
app.get('*', (_req, res) => res.status(200).end());
|
||||
|
||||
return { app, barrier };
|
||||
|
||||
+25
-10
@@ -23,13 +23,15 @@ import {
|
||||
SchedulerServiceTaskRunner,
|
||||
SchedulerServiceTaskScheduleDefinition,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Counter, Histogram, metrics } from '@opentelemetry/api';
|
||||
import { Counter, Histogram, metrics, trace } from '@opentelemetry/api';
|
||||
import { Knex } from 'knex';
|
||||
import { Duration } from 'luxon';
|
||||
import { LocalTaskWorker } from './LocalTaskWorker';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
import { TaskSettingsV2 } from './types';
|
||||
import { validateId } from './util';
|
||||
import { TRACER_ID, validateId } from './util';
|
||||
|
||||
const tracer = trace.getTracer(TRACER_ID);
|
||||
|
||||
/**
|
||||
* Implements the actual task management.
|
||||
@@ -85,7 +87,7 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
|
||||
const knex = await this.databaseFactory();
|
||||
const worker = new TaskWorker(
|
||||
task.id,
|
||||
this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }),
|
||||
this.instrumentedFunction(task, scope),
|
||||
knex,
|
||||
this.logger.child({ task: task.id }),
|
||||
);
|
||||
@@ -93,7 +95,7 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
|
||||
} else {
|
||||
const worker = new LocalTaskWorker(
|
||||
task.id,
|
||||
this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }),
|
||||
this.instrumentedFunction(task, scope),
|
||||
this.logger.child({ task: task.id }),
|
||||
);
|
||||
worker.start(settings, { signal: task.signal });
|
||||
@@ -121,20 +123,33 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
|
||||
return this.allScheduledTasks;
|
||||
}
|
||||
|
||||
private wrapInMetrics(
|
||||
fn: SchedulerServiceTaskFunction,
|
||||
opts: { labels: Record<string, string> },
|
||||
private instrumentedFunction(
|
||||
task: SchedulerServiceTaskInvocationDefinition,
|
||||
scope: string,
|
||||
): SchedulerServiceTaskFunction {
|
||||
return async abort => {
|
||||
const labels = {
|
||||
...opts.labels,
|
||||
const labels: Record<string, string> = {
|
||||
taskId: task.id,
|
||||
scope,
|
||||
};
|
||||
this.counter.add(1, { ...labels, result: 'started' });
|
||||
|
||||
const startTime = process.hrtime();
|
||||
|
||||
try {
|
||||
await fn(abort);
|
||||
await tracer.startActiveSpan(`task ${task.id}`, async span => {
|
||||
try {
|
||||
span.setAttributes(labels);
|
||||
await task.fn(abort);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
span.recordException(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
labels.result = 'completed';
|
||||
} catch (ex) {
|
||||
labels.result = 'failed';
|
||||
|
||||
@@ -18,6 +18,8 @@ import { InputError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
export const TRACER_ID = 'backstage-service-scheduler';
|
||||
|
||||
// Keep the IDs compatible with e.g. Prometheus labels
|
||||
export function validateId(id: string) {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LoggerService, UrlReaderService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
UrlReaderService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { ReaderFactory } from './types';
|
||||
import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
|
||||
import { AzureUrlReader } from './AzureUrlReader';
|
||||
@@ -40,7 +43,7 @@ import { HarnessUrlReader } from './HarnessUrlReader';
|
||||
*/
|
||||
export type UrlReadersOptions = {
|
||||
/** Root config object */
|
||||
config: Config;
|
||||
config: RootConfigService;
|
||||
/** Logger used by all the readers */
|
||||
logger: LoggerService;
|
||||
/** A list of factories used to construct individual readers that match on URLs */
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import { Readable } from 'stream';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
UrlReaderService,
|
||||
UrlReaderServiceReadTreeResponse,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
@@ -40,7 +40,7 @@ export type UrlReaderPredicateTuple = {
|
||||
* @public
|
||||
*/
|
||||
export type ReaderFactory = (options: {
|
||||
config: Config;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
}) => UrlReaderPredicateTuple[];
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
*/
|
||||
|
||||
export { createBackend } from './CreateBackend';
|
||||
export { discoveryFeatureLoader } from './discoveryFeatureLoader';
|
||||
|
||||
@@ -1,5 +1,98 @@
|
||||
# @backstage/backend-dynamic-feature-service
|
||||
|
||||
## 0.4.0-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-defaults@0.5.0-next.1
|
||||
- @backstage/backend-common@0.25.0-next.1
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/backend-app-api@0.10.0-next.1
|
||||
- @backstage/plugin-catalog-backend@1.25.3-next.1
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-app-node@0.1.25-next.1
|
||||
- @backstage/plugin-events-backend@0.3.12-next.1
|
||||
- @backstage/plugin-events-node@0.4.0-next.1
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
- @backstage/plugin-permission-node@0.8.3-next.1
|
||||
- @backstage/plugin-scaffolder-node@0.4.11-next.1
|
||||
- @backstage/plugin-search-backend-node@1.3.2-next.1
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
|
||||
## 0.4.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9080f57: **BREAKING**: `dynamicPluginsServiceFactory` is no longer callable as a function. If you need to provide options to make a custom factory, use `dynamicPluginsSchemasServiceFactoryWithOptions` instead.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd38da8: Deprecate the `dynamicPluginsServiceRef`, `dynamicPluginsServiceFactory` and `dynamicPluginsServiceFactoryWithOptions` in favor of using the `dynamicPluginsFeatureDiscoveryLoader` to discover dynamic features in a new backend system.
|
||||
|
||||
See usage examples below:
|
||||
|
||||
Example using the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance:
|
||||
|
||||
```ts
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service';
|
||||
//...
|
||||
|
||||
const backend = createBackend();
|
||||
backend.add(dynamicPluginsFeatureDiscoveryLoader);
|
||||
//...
|
||||
backend.start();
|
||||
```
|
||||
|
||||
Passing options to the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance:
|
||||
|
||||
```ts
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service';
|
||||
import { myCustomModuleLoader } from './myCustomModuleLoader';
|
||||
//...
|
||||
|
||||
const backend = createBackend();
|
||||
backend.add(
|
||||
dynamicPluginsFeatureDiscoveryLoader({
|
||||
moduleLoader: myCustomModuleLoader,
|
||||
}),
|
||||
);
|
||||
//...
|
||||
backend.start();
|
||||
```
|
||||
|
||||
- e27f889: Relax type check for a plugin's default export to also accept a BackendFeature defined as a function instead of an object
|
||||
- d425fc4: Modules, plugins, and services are now `BackendFeature`, not a function that returns a feature.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-app-api@0.10.0-next.0
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/plugin-app-node@0.1.25-next.0
|
||||
- @backstage/backend-defaults@0.5.0-next.0
|
||||
- @backstage/plugin-permission-node@0.8.3-next.0
|
||||
- @backstage/backend-common@0.25.0-next.0
|
||||
- @backstage/plugin-catalog-backend@1.25.3-next.0
|
||||
- @backstage/plugin-events-backend@0.3.12-next.0
|
||||
- @backstage/plugin-events-node@0.4.0-next.0
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/plugin-scaffolder-node@0.4.11-next.0
|
||||
- @backstage/plugin-search-backend-node@1.3.2-next.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
- @backstage/plugin-search-common@1.2.14
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -112,7 +112,13 @@ export interface DynamicPluginsFactoryOptions {
|
||||
moduleLoader?(logger: LoggerService): ModuleLoader;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export const dynamicPluginsFeatureDiscoveryLoader: ((
|
||||
options?: DynamicPluginsFactoryOptions,
|
||||
) => BackendFeature) &
|
||||
BackendFeature;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const dynamicPluginsFeatureDiscoveryServiceFactory: ServiceFactory<
|
||||
FeatureDiscoveryService,
|
||||
'root',
|
||||
@@ -154,19 +160,19 @@ export const dynamicPluginsSchemasServiceFactoryWithOptions: (
|
||||
options?: DynamicPluginsSchemasOptions,
|
||||
) => ServiceFactory<DynamicPluginsSchemasService, 'root', 'singleton'>;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const dynamicPluginsServiceFactory: ServiceFactory<
|
||||
DynamicPluginProvider,
|
||||
'root',
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const dynamicPluginsServiceFactoryWithOptions: (
|
||||
options?: DynamicPluginsFactoryOptions,
|
||||
) => ServiceFactory<DynamicPluginProvider, 'root', 'singleton'>;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const dynamicPluginsServiceRef: ServiceRef<
|
||||
DynamicPluginProvider,
|
||||
'root',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-dynamic-feature-service",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0-next.1",
|
||||
"description": "Backstage dynamic feature service",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -36,6 +36,7 @@ export {
|
||||
dynamicPluginsServiceFactory,
|
||||
dynamicPluginsServiceFactoryWithOptions,
|
||||
dynamicPluginsServiceRef,
|
||||
dynamicPluginsFeatureDiscoveryLoader,
|
||||
} from './plugin-manager';
|
||||
|
||||
export type {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
BackendFeature,
|
||||
LoggerService,
|
||||
coreServices,
|
||||
createBackendFeatureLoader,
|
||||
createServiceFactory,
|
||||
createServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -214,6 +215,7 @@ export class DynamicPluginManager implements DynamicPluginProvider {
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated The `featureDiscoveryService` is deprecated in favor of using {@link dynamicPluginsFeatureDiscoveryLoader} instead.
|
||||
*/
|
||||
export const dynamicPluginsServiceRef = createServiceRef<DynamicPluginProvider>(
|
||||
{
|
||||
@@ -231,6 +233,7 @@ export interface DynamicPluginsFactoryOptions {
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use {@link dynamicPluginsFeatureDiscoveryLoader} instead.
|
||||
*/
|
||||
export const dynamicPluginsServiceFactoryWithOptions = (
|
||||
options?: DynamicPluginsFactoryOptions,
|
||||
@@ -253,6 +256,7 @@ export const dynamicPluginsServiceFactoryWithOptions = (
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use {@link dynamicPluginsFeatureDiscoveryLoader} instead.
|
||||
*/
|
||||
export const dynamicPluginsServiceFactory =
|
||||
dynamicPluginsServiceFactoryWithOptions();
|
||||
@@ -292,6 +296,7 @@ class DynamicPluginsEnabledFeatureDiscoveryService
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated The `featureDiscoveryService` is deprecated in favor of using {@link dynamicPluginsFeatureDiscoveryLoader} instead.
|
||||
*/
|
||||
export const dynamicPluginsFeatureDiscoveryServiceFactory =
|
||||
createServiceFactory({
|
||||
@@ -305,6 +310,66 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory =
|
||||
},
|
||||
});
|
||||
|
||||
const dynamicPluginsFeatureDiscoveryLoaderWithOptions = (
|
||||
options?: DynamicPluginsFactoryOptions,
|
||||
) =>
|
||||
createBackendFeatureLoader({
|
||||
deps: {
|
||||
config: coreServices.rootConfig,
|
||||
logger: coreServices.rootLogger,
|
||||
},
|
||||
async loader({ config, logger }) {
|
||||
const manager = await DynamicPluginManager.create({
|
||||
config,
|
||||
logger,
|
||||
preferAlpha: true,
|
||||
moduleLoader: options?.moduleLoader?.(logger),
|
||||
});
|
||||
const service = new DynamicPluginsEnabledFeatureDiscoveryService(manager);
|
||||
const { features } = await service.getBackendFeatures();
|
||||
return features;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A backend feature loader that uses the dynamic plugins system to discover features.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
* Using the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance:
|
||||
* ```ts
|
||||
* //...
|
||||
* import { createBackend } from '@backstage/backend-defaults';
|
||||
* import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service';
|
||||
*
|
||||
* const backend = createBackend();
|
||||
* backend.add(dynamicPluginsFeatureDiscoveryLoader);
|
||||
* //...
|
||||
* backend.start();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Passing options to the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance:
|
||||
* ```ts
|
||||
* //...
|
||||
* import { createBackend } from '@backstage/backend-defaults';
|
||||
* import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service';
|
||||
* import { myCustomModuleLoader } from './myCustomModuleLoader';
|
||||
*
|
||||
* const backend = createBackend();
|
||||
* backend.add(dynamicPluginsFeatureDiscoveryLoader({
|
||||
* moduleLoader: myCustomModuleLoader
|
||||
* }));
|
||||
* //...
|
||||
* backend.start();
|
||||
* ```
|
||||
*/
|
||||
export const dynamicPluginsFeatureDiscoveryLoader = Object.assign(
|
||||
dynamicPluginsFeatureDiscoveryLoaderWithOptions,
|
||||
dynamicPluginsFeatureDiscoveryLoaderWithOptions(),
|
||||
);
|
||||
|
||||
function isBackendFeature(value: unknown): value is BackendFeature {
|
||||
return (
|
||||
!!value &&
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# example-backend-legacy
|
||||
|
||||
## 0.2.102-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-defaults@0.5.0-next.1
|
||||
- @backstage/backend-common@0.25.0-next.1
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/catalog-client@1.6.7-next.0
|
||||
- @backstage/plugin-catalog-backend@1.25.3-next.1
|
||||
- @backstage/plugin-scaffolder-backend@1.25.0-next.1
|
||||
- @backstage/plugin-kubernetes-backend@0.18.6-next.1
|
||||
- @backstage/plugin-techdocs-backend@1.10.13-next.1
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/plugin-app-backend@0.3.74-next.1
|
||||
- @backstage/plugin-auth-backend@0.23.0-next.1
|
||||
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.0-next.1
|
||||
- @backstage/plugin-catalog-backend-module-unprocessed@0.5.0-next.1
|
||||
- @backstage/plugin-catalog-node@1.12.7-next.1
|
||||
- @backstage/plugin-devtools-backend@0.4.0-next.1
|
||||
- @backstage/plugin-events-backend@0.3.12-next.1
|
||||
- @backstage/plugin-events-node@0.4.0-next.1
|
||||
- @backstage/plugin-permission-backend@0.5.49-next.1
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
- @backstage/plugin-permission-node@0.8.3-next.1
|
||||
- @backstage/plugin-proxy-backend@0.5.6-next.1
|
||||
- @backstage/plugin-scaffolder-backend-module-confluence-to-markdown@0.3.0-next.1
|
||||
- @backstage/plugin-scaffolder-backend-module-gitlab@0.5.0-next.1
|
||||
- @backstage/plugin-scaffolder-backend-module-rails@0.5.0-next.1
|
||||
- @backstage/plugin-search-backend@1.5.17-next.1
|
||||
- @backstage/plugin-search-backend-module-catalog@0.2.2-next.1
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@1.5.6-next.1
|
||||
- @backstage/plugin-search-backend-module-explore@0.2.2-next.1
|
||||
- @backstage/plugin-search-backend-module-pg@0.5.35-next.1
|
||||
- @backstage/plugin-search-backend-module-techdocs@0.2.2-next.1
|
||||
- @backstage/plugin-search-backend-node@1.3.2-next.1
|
||||
- @backstage/plugin-signals-backend@0.2.0-next.1
|
||||
- @backstage/plugin-signals-node@0.1.11-next.1
|
||||
|
||||
## 0.2.102-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-techdocs-backend@1.10.13-next.0
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/plugin-search-backend@1.5.17-next.0
|
||||
- @backstage/plugin-kubernetes-backend@0.18.6-next.0
|
||||
- @backstage/plugin-scaffolder-backend@1.25.0-next.0
|
||||
- @backstage/plugin-app-backend@0.3.74-next.0
|
||||
- @backstage/plugin-signals-backend@0.2.0-next.0
|
||||
- @backstage/backend-defaults@0.5.0-next.0
|
||||
- @backstage/plugin-search-backend-module-techdocs@0.2.2-next.0
|
||||
- @backstage/plugin-search-backend-module-catalog@0.2.2-next.0
|
||||
- @backstage/plugin-search-backend-module-explore@0.2.2-next.0
|
||||
- @backstage/plugin-permission-node@0.8.3-next.0
|
||||
- @backstage/plugin-auth-backend@0.23.0-next.0
|
||||
- @backstage/backend-common@0.25.0-next.0
|
||||
- @backstage/plugin-catalog-backend@1.25.3-next.0
|
||||
- @backstage/plugin-events-backend@0.3.12-next.0
|
||||
- @backstage/plugin-permission-backend@0.5.49-next.0
|
||||
- @backstage/plugin-proxy-backend@0.5.6-next.0
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@1.5.6-next.0
|
||||
- @backstage/plugin-search-backend-module-pg@0.5.35-next.0
|
||||
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.0-next.0
|
||||
- @backstage/plugin-catalog-backend-module-unprocessed@0.5.0-next.0
|
||||
- @backstage/plugin-devtools-backend@0.4.0-next.0
|
||||
- @backstage/plugin-events-node@0.4.0-next.0
|
||||
- @backstage/plugin-scaffolder-backend-module-confluence-to-markdown@0.3.0-next.0
|
||||
- @backstage/plugin-scaffolder-backend-module-gitlab@0.5.0-next.0
|
||||
- @backstage/plugin-scaffolder-backend-module-rails@0.5.0-next.0
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/plugin-catalog-node@1.12.7-next.0
|
||||
- @backstage/plugin-search-backend-node@1.3.2-next.0
|
||||
- @backstage/plugin-signals-node@0.1.11-next.0
|
||||
- @backstage/catalog-client@1.6.6
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
|
||||
## 0.2.101
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend-legacy",
|
||||
"version": "0.2.101",
|
||||
"version": "0.2.102-next.1",
|
||||
"backstage": {
|
||||
"role": "backend"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @backstage/backend-openapi-utils
|
||||
|
||||
## 0.1.18-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@0.9.0-next.1
|
||||
- @backstage/errors@1.2.4
|
||||
|
||||
## 0.1.18-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@0.9.0-next.0
|
||||
- @backstage/errors@1.2.4
|
||||
|
||||
## 0.1.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/backend-openapi-utils",
|
||||
"description": "OpenAPI typescript support.",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.18-next.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# @backstage/backend-plugin-api
|
||||
|
||||
## 0.9.0-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-auth-node@0.5.2-next.1
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
|
||||
## 0.9.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 19ff127: **BREAKING**: The deprecated identity and token manager services have been removed. This means that `coreServices.identity` and `coreServices.tokenManager` are gone, along with related types and utilities in other packages.
|
||||
- f687050: Removed the following deprecated exports
|
||||
|
||||
- `BackendPluginConfig` use `CreateBackendPluginOptions`
|
||||
- `BackendModuleConfig` use `CreateBackendModuleOptions`
|
||||
- `ExtensionPointConfig` use `CreateExtensionPointOptions`
|
||||
|
||||
- 4d82481: Removed deprecated `ServiceFactoryOrFunction` type.
|
||||
- d425fc4: **BREAKING**: The return values from `createBackendPlugin`, `createBackendModule`, and `createServiceFactory` are now simply `BackendFeature` and `ServiceFactory`, instead of the previously deprecated form of a function that returns them. For this reason, `createServiceFactory` also no longer accepts the callback form where you provide direct options to the service. This also affects all `coreServices.*` service refs.
|
||||
|
||||
This may in particular affect tests; if you were effectively doing `createBackendModule({...})()` (note the parentheses), you can now remove those extra parentheses at the end. You may encounter cases of this in your `packages/backend/src/index.ts` too, where you add plugins, modules, and services. If you were using `createServiceFactory` with a function as its argument for the purpose of passing in options, this pattern has been deprecated for a while and is no longer supported. You may want to explore the new multiton patterns to achieve your goals, or moving settings to app-config.
|
||||
|
||||
As part of this change, the `IdentityFactoryOptions` type was removed, and can no longer be used to tweak that service. The identity service was also deprecated some time ago, and you will want to [migrate to the new auth system](https://backstage.io/docs/tutorials/auth-service-migration) if you still rely on it.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd38da8: Deprecate the `featureDiscoveryServiceRef` in favor of using the new `discoveryFeatureLoader` instead.
|
||||
- 66dbf0a: Allow the cache service to accept the human duration format for TTL
|
||||
- 0b2a402: Updates to the config schema to match reality
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-auth-node@0.5.2-next.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/types@1.1.1
|
||||
- @backstage/plugin-permission-common@0.8.1
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface FeatureDiscoveryService {
|
||||
}>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
// @alpha @deprecated
|
||||
export const featureDiscoveryServiceRef: ServiceRef<
|
||||
FeatureDiscoveryService,
|
||||
'root',
|
||||
|
||||
@@ -162,12 +162,12 @@ export interface CacheService {
|
||||
|
||||
// @public
|
||||
export type CacheServiceOptions = {
|
||||
defaultTtl?: number;
|
||||
defaultTtl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CacheServiceSetOptions = {
|
||||
ttl?: number;
|
||||
ttl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -514,7 +514,7 @@ export interface RootLoggerService extends LoggerService {}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RootServiceFactoryOptions<
|
||||
TService, // TODO(Rugvip): Can we forward the entire service ref type here instead of forwarding each type arg once the callback form is gone?
|
||||
TService,
|
||||
TInstances extends 'singleton' | 'multiton',
|
||||
TImpl extends TService,
|
||||
TDeps extends {
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
backend?: {
|
||||
/**
|
||||
* An absolute path to a directory that can be used as a working dir, for
|
||||
* example as scratch space for large operations.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Note that this must be an absolute path.
|
||||
*
|
||||
* If not set, the operating system's designated temporary directory is
|
||||
* commonly used, but that is implementation defined per plugin.
|
||||
*
|
||||
* Plugins are encouraged to heed this config setting if present, to allow
|
||||
* deployment in severely locked-down or limited environments.
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-plugin-api",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0-next.1",
|
||||
"description": "Core API used by Backstage backend plugins",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
@@ -40,7 +40,8 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
@@ -67,5 +68,6 @@
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^"
|
||||
}
|
||||
},
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface FeatureDiscoveryService {
|
||||
/**
|
||||
* An optional service that can be used to dynamically load in additional BackendFeatures at runtime.
|
||||
* @alpha
|
||||
* @deprecated The `featureDiscoveryServiceRef` is deprecated in favor of using {@link @backstage/backend-defaults#discoveryFeatureLoader} instead.
|
||||
*/
|
||||
export const featureDiscoveryServiceRef =
|
||||
createServiceRef<FeatureDiscoveryService>({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { HumanDuration, JsonValue } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Options passed to {@link CacheService.set}.
|
||||
@@ -23,10 +23,10 @@ import { JsonValue } from '@backstage/types';
|
||||
*/
|
||||
export type CacheServiceSetOptions = {
|
||||
/**
|
||||
* Optional TTL in milliseconds. Defaults to the TTL provided when the client
|
||||
* Optional TTL (in milliseconds if given as a number). Defaults to the TTL provided when the client
|
||||
* was set up (or no TTL if none are provided).
|
||||
*/
|
||||
ttl?: number;
|
||||
ttl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,11 +36,11 @@ export type CacheServiceSetOptions = {
|
||||
*/
|
||||
export type CacheServiceOptions = {
|
||||
/**
|
||||
* An optional default TTL (in milliseconds) to be set when getting a client
|
||||
* An optional default TTL (in milliseconds if given as a number) to be set when getting a client
|
||||
* instance. If not provided, data will persist indefinitely by default (or
|
||||
* can be configured per entry at set-time).
|
||||
*/
|
||||
defaultTtl?: number;
|
||||
defaultTtl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,7 +165,7 @@ type ServiceRefsToInstances<
|
||||
|
||||
/** @public */
|
||||
export interface RootServiceFactoryOptions<
|
||||
TService, // TODO(Rugvip): Can we forward the entire service ref type here instead of forwarding each type arg once the callback form is gone?
|
||||
TService,
|
||||
TInstances extends 'singleton' | 'multiton',
|
||||
TImpl extends TService,
|
||||
TDeps extends { [name in string]: ServiceRef<unknown> },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
# @backstage/backend-tasks
|
||||
|
||||
> [!CAUTION]
|
||||
> This package is deprecated and will be removed in a near future.
|
||||
|
||||
Common distributed task management for Backstage backends.
|
||||
|
||||
## Usage
|
||||
|
||||
> [!CAUTION]
|
||||
> Please note that the documentation below is only valid for versions equal to or below `0.5.28-next.3`.
|
||||
> As this package will be deleted soon, we recommend that you migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead, or use `DefaultSchedulerService` from `@backstage/backend-defaults`. Here are the [backend](https://backstage.io/docs/backend-system/building-backends/migrating) and [plugin](https://backstage.io/docs/backend-system/building-plugins-and-modules/migrating) migration guides.
|
||||
|
||||
Add the library to your backend package:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/backend add @backstage/backend-tasks
|
||||
```
|
||||
|
||||
then make use of its facilities as necessary:
|
||||
|
||||
```typescript
|
||||
import { TaskScheduler } from '@backstage/backend-tasks';
|
||||
|
||||
const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin');
|
||||
|
||||
await scheduler.scheduleTask({
|
||||
id: 'refresh_things',
|
||||
frequency: { cron: '*/5 * * * *' }, // every 5 minutes, also supports Duration
|
||||
timeout: { minutes: 15 },
|
||||
fn: async () => {
|
||||
await entityProvider.run();
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -1,114 +0,0 @@
|
||||
## API Report File for "@backstage/backend-tasks"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Config } from '@backstage/config';
|
||||
import { Duration } from 'luxon';
|
||||
import { HumanDuration as HumanDuration_2 } from '@backstage/types';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { LegacyRootDatabaseService } from '@backstage/backend-common';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
|
||||
// @public @deprecated
|
||||
export type HumanDuration = HumanDuration_2;
|
||||
|
||||
// @public @deprecated
|
||||
export interface PluginTaskScheduler {
|
||||
createScheduledTaskRunner(schedule: TaskScheduleDefinition): TaskRunner;
|
||||
getScheduledTasks(): Promise<TaskDescriptor[]>;
|
||||
scheduleTask(
|
||||
task: TaskScheduleDefinition & TaskInvocationDefinition,
|
||||
): Promise<void>;
|
||||
triggerTask(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function readTaskScheduleDefinitionFromConfig(
|
||||
config: Config,
|
||||
): TaskScheduleDefinition;
|
||||
|
||||
// @public @deprecated
|
||||
export type TaskDescriptor = {
|
||||
id: string;
|
||||
scope: 'global' | 'local';
|
||||
settings: {
|
||||
version: number;
|
||||
} & JsonObject;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type TaskFunction =
|
||||
| ((abortSignal: AbortSignal) => void | Promise<void>)
|
||||
| (() => void | Promise<void>);
|
||||
|
||||
// @public @deprecated
|
||||
export interface TaskInvocationDefinition {
|
||||
fn: TaskFunction;
|
||||
id: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export interface TaskRunner {
|
||||
run(task: TaskInvocationDefinition): Promise<void>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export interface TaskScheduleDefinition {
|
||||
frequency:
|
||||
| {
|
||||
cron: string;
|
||||
}
|
||||
| Duration
|
||||
| HumanDuration_2
|
||||
/**
|
||||
* This task will only run when manually triggered with the `triggerTask` method; no automatic
|
||||
* scheduling. This is useful for locking of global tasks that should not be run concurrently.
|
||||
*/
|
||||
| {
|
||||
trigger: 'manual';
|
||||
};
|
||||
initialDelay?: Duration | HumanDuration_2;
|
||||
scope?: 'global' | 'local';
|
||||
timeout: Duration | HumanDuration_2;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export interface TaskScheduleDefinitionConfig {
|
||||
frequency:
|
||||
| {
|
||||
cron: string;
|
||||
}
|
||||
| string
|
||||
| HumanDuration_2;
|
||||
initialDelay?: string | HumanDuration_2;
|
||||
scope?: 'global' | 'local';
|
||||
timeout: string | HumanDuration_2;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export class TaskScheduler {
|
||||
constructor(
|
||||
databaseManager: LegacyRootDatabaseService,
|
||||
logger: LoggerService,
|
||||
);
|
||||
// @deprecated
|
||||
forPlugin(pluginId: string): PluginTaskScheduler;
|
||||
// @deprecated (undocumented)
|
||||
static forPlugin(opts: {
|
||||
pluginId: string;
|
||||
databaseManager: PluginDatabaseManager;
|
||||
logger: LoggerService;
|
||||
}): PluginTaskScheduler;
|
||||
// @deprecated (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
databaseManager?: LegacyRootDatabaseService;
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): TaskScheduler;
|
||||
}
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-backend-tasks
|
||||
title: '@backstage/backend-tasks'
|
||||
description: Common distributed task management library for Backstage backends
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-node-library
|
||||
owner: maintainers
|
||||
@@ -1,2 +0,0 @@
|
||||
# Knip report
|
||||
|
||||
@@ -1,64 +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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
//
|
||||
// tasks
|
||||
//
|
||||
await knex.schema.createTable('backstage_backend_tasks__tasks', table => {
|
||||
table.comment('Tasks used for scheduling work on multiple workers');
|
||||
table
|
||||
.string('id')
|
||||
.primary()
|
||||
.notNullable()
|
||||
.comment('The unique ID of this particular task');
|
||||
table
|
||||
.text('settings_json')
|
||||
.notNullable()
|
||||
.comment('JSON serialized object with properties for this task');
|
||||
table
|
||||
.dateTime('next_run_start_at')
|
||||
.notNullable()
|
||||
.comment('The next time that the task should be started');
|
||||
table
|
||||
.text('current_run_ticket')
|
||||
.nullable()
|
||||
.comment('A unique ticket for the current task run');
|
||||
table
|
||||
.dateTime('current_run_started_at')
|
||||
.nullable()
|
||||
.comment('The time that the current task run started');
|
||||
table
|
||||
.dateTime('current_run_expires_at')
|
||||
.nullable()
|
||||
.comment('The time that the current task run will time out');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
//
|
||||
// tasks
|
||||
//
|
||||
await knex.schema.dropTable('backstage_backend_tasks__tasks');
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('backstage_backend_tasks__tasks', table => {
|
||||
table.setNullable('next_run_start_at');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex
|
||||
.delete()
|
||||
.from('backstage_backend_tasks__tasks')
|
||||
.where({ next_run_start_at: null });
|
||||
await knex.schema.alterTable('backstage_backend_tasks__tasks', table => {
|
||||
table.dropNullable('next_run_start_at');
|
||||
});
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"name": "@backstage/backend-tasks",
|
||||
"version": "0.6.0",
|
||||
"description": "Common distributed task management library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-tasks"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations/**/*.{js,d.ts}"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
"@types/luxon": "^3.0.0",
|
||||
"cron": "^3.0.0",
|
||||
"knex": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolvePackagePath } from '@backstage/backend-plugin-api';
|
||||
import { Knex } from 'knex';
|
||||
import { DB_MIGRATIONS_TABLE } from './tables';
|
||||
|
||||
export async function migrateBackendTasks(knex: Knex): Promise<void> {
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/backend-tasks',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
tableName: DB_MIGRATIONS_TABLE,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common distributed task management library for Backstage backends
|
||||
*
|
||||
* @remarks
|
||||
* This package is deprecated and will be removed in a near future.
|
||||
* Please migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead, or use `DefaultSchedulerService` from `@backstage/backend-defaults`.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './deprecated';
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import fs from 'fs';
|
||||
|
||||
const migrationsDir = `${__dirname}/../migrations`;
|
||||
const migrationsFiles = fs.readdirSync(migrationsDir).sort();
|
||||
|
||||
async function migrateUpOnce(knex: Knex): Promise<void> {
|
||||
await knex.migrate.up({ directory: migrationsDir });
|
||||
}
|
||||
|
||||
async function migrateDownOnce(knex: Knex): Promise<void> {
|
||||
await knex.migrate.down({ directory: migrationsDir });
|
||||
}
|
||||
|
||||
async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
|
||||
const index = migrationsFiles.indexOf(target);
|
||||
if (index === -1) {
|
||||
throw new Error(`Migration ${target} not found`);
|
||||
}
|
||||
for (let i = 0; i < index; i++) {
|
||||
await migrateUpOnce(knex);
|
||||
}
|
||||
}
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('migrations', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20210928160613_init.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20210928160613_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: expect.anything(),
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
// This looks odd - you might expect a .toThrow at the end but that
|
||||
// actually is flaky for some reason specifically on sqlite when
|
||||
// performing multiple runs in sequence
|
||||
await expect(knex('backstage_backend_tasks__tasks')).rejects.toEqual(
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20240712211735_nullable_next_run.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20240712211735_nullable_next_run.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.raw('null'),
|
||||
});
|
||||
|
||||
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
|
||||
{
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: null,
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await expect(
|
||||
knex('backstage_backend_tasks__tasks').insert({
|
||||
id: 'test',
|
||||
settings_json: '{}',
|
||||
next_run_start_at: knex.raw('null'),
|
||||
}),
|
||||
).rejects.toEqual(expect.anything());
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,25 +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 { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Settings } from 'luxon';
|
||||
|
||||
// TS still thinks that methods can return null / placeholders, but we still want to throw as soon as possible when things go wrong
|
||||
Settings.throwOnInvalid = true;
|
||||
|
||||
TestDatabases.setDefaults({
|
||||
ids: ['MYSQL_8', 'POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'],
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LocalTaskWorker } from './LocalTaskWorker';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('LocalTaskWorker', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
it('runs the happy path (with iso duration) and handles cancellation', async () => {
|
||||
const fn = jest.fn();
|
||||
const controller = new AbortController();
|
||||
|
||||
const worker = new LocalTaskWorker('a', fn, logger);
|
||||
worker.start(
|
||||
{
|
||||
version: 2,
|
||||
initialDelayDuration: 'PT0.2S',
|
||||
cadence: 'PT0.2S',
|
||||
timeoutAfterDuration: 'PT1S',
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
// TODO(freben): Rewrite to fake timers - tried, but it wouldn't work
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
controller.abort();
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('runs the happy path (with a cron expression) and handles cancellation', async () => {
|
||||
const fn = jest.fn();
|
||||
const controller = new AbortController();
|
||||
|
||||
// Await until system time is just past a second boundary (since cron is
|
||||
// wall clock based)
|
||||
await new Promise(r => setTimeout(r, 1000 - (Date.now() % 1000) + 10));
|
||||
|
||||
const worker = new LocalTaskWorker('a', fn, logger);
|
||||
worker.start(
|
||||
{
|
||||
version: 2,
|
||||
initialDelayDuration: 'PT0.2S',
|
||||
cadence: '* * * * * *',
|
||||
timeoutAfterDuration: 'PT1S',
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
// TODO(freben): Rewrite to fake timers - tried, but it wouldn't work
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
controller.abort();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('can trigger to abort wait', async () => {
|
||||
const fn = jest.fn();
|
||||
const controller = new AbortController();
|
||||
|
||||
const worker = new LocalTaskWorker('a', fn, logger);
|
||||
worker.start(
|
||||
{
|
||||
version: 2,
|
||||
initialDelayDuration: 'PT0.2S',
|
||||
cadence: 'PT0.2S',
|
||||
timeoutAfterDuration: 'PT1S',
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
// TODO(freben): Rewrite to fake timers - tried, but it wouldn't work
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
worker.trigger();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
controller.abort();
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConflictError } from '@backstage/errors';
|
||||
import { CronTime } from 'cron';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { TaskFunction, TaskSettingsV2 } from './types';
|
||||
import { delegateAbortController, sleep } from './util';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Implements tasks that run locally without cross-host collaboration.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export class LocalTaskWorker {
|
||||
private abortWait: AbortController | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly taskId: string,
|
||||
private readonly fn: TaskFunction,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) {
|
||||
this.logger.info(
|
||||
`Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
let attemptNum = 1;
|
||||
for (;;) {
|
||||
try {
|
||||
if (settings.initialDelayDuration) {
|
||||
await this.sleep(
|
||||
Duration.fromISO(settings.initialDelayDuration),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
while (!options?.signal?.aborted) {
|
||||
const startTime = process.hrtime();
|
||||
await this.runOnce(settings, options?.signal);
|
||||
const timeTaken = process.hrtime(startTime);
|
||||
await this.waitUntilNext(
|
||||
settings,
|
||||
(timeTaken[0] + timeTaken[1] / 1e9) * 1000,
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info(`Task worker finished: ${this.taskId}`);
|
||||
attemptNum = 0;
|
||||
break;
|
||||
} catch (e) {
|
||||
attemptNum += 1;
|
||||
this.logger.warn(
|
||||
`Task worker failed unexpectedly, attempt number ${attemptNum}, ${e}`,
|
||||
);
|
||||
await sleep(Duration.fromObject({ seconds: 1 }));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
trigger(): void {
|
||||
if (!this.abortWait) {
|
||||
throw new ConflictError(`Task ${this.taskId} is currently running`);
|
||||
}
|
||||
this.abortWait.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a single attempt at running the task to completion.
|
||||
*/
|
||||
private async runOnce(
|
||||
settings: TaskSettingsV2,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
// Abort the task execution either if the worker is stopped, or if the
|
||||
// task timeout is hit
|
||||
const taskAbortController = delegateAbortController(signal);
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
taskAbortController.abort();
|
||||
}, Duration.fromISO(settings.timeoutAfterDuration).as('milliseconds'));
|
||||
|
||||
try {
|
||||
await this.fn(taskAbortController.signal);
|
||||
} catch (e) {
|
||||
// ignore intentionally
|
||||
}
|
||||
|
||||
// release resources
|
||||
clearTimeout(timeoutHandle);
|
||||
taskAbortController.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleeps until it's time to run the task again.
|
||||
*/
|
||||
private async waitUntilNext(
|
||||
settings: TaskSettingsV2,
|
||||
lastRunMillis: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCron = !settings.cadence.startsWith('P');
|
||||
let dt: number;
|
||||
|
||||
if (isCron) {
|
||||
const nextRun = +new CronTime(settings.cadence).sendAt().toJSDate();
|
||||
dt = nextRun - Date.now();
|
||||
} else {
|
||||
dt =
|
||||
Duration.fromISO(settings.cadence).as('milliseconds') - lastRunMillis;
|
||||
}
|
||||
|
||||
dt = Math.max(dt, 0);
|
||||
|
||||
this.logger.debug(
|
||||
`task: ${this.taskId} will next occur around ${DateTime.now().plus(
|
||||
Duration.fromMillis(dt),
|
||||
)}`,
|
||||
);
|
||||
|
||||
await this.sleep(Duration.fromMillis(dt), signal);
|
||||
}
|
||||
|
||||
private async sleep(
|
||||
duration: Duration,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
this.abortWait = delegateAbortController(abortSignal);
|
||||
await sleep(duration, this.abortWait.signal);
|
||||
this.abortWait.abort(); // cleans up resources
|
||||
this.abortWait = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
mockServices,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { Duration } from 'luxon';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import {
|
||||
parseDuration,
|
||||
PluginTaskSchedulerImpl,
|
||||
} from './PluginTaskSchedulerImpl';
|
||||
|
||||
function defer() {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('PluginTaskManagerImpl', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Make sure all databases are running before mocking timers, in case of testcontainers
|
||||
await Promise.all(
|
||||
databases.eachSupportedId().map(([id]) => databases.init(id)),
|
||||
);
|
||||
|
||||
jest.useFakeTimers();
|
||||
}, 60_000);
|
||||
|
||||
async function init(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
const logger = mockServices.logger.mock();
|
||||
const manager = new PluginTaskSchedulerImpl(async () => knex, logger);
|
||||
return { knex, manager };
|
||||
}
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with global scope', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the v1 happy path, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
fn,
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the v2 happy path, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('triggerTask with global scope', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can manually trigger a task, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await manager.triggerTask('task1');
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cant trigger a non-existent task, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'cant trigger a running task, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const { promise, resolve } = defer();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await promise;
|
||||
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask with local scope', () => {
|
||||
it('can run the v1 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: { milliseconds: 5000 },
|
||||
frequency: { milliseconds: 5000 },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
|
||||
it('can run the v2 happy path', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('triggerTask with local scope', () => {
|
||||
it('can manually trigger a task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
initialDelay: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await manager.triggerTask('task1');
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
}, 60_000);
|
||||
|
||||
it('cant trigger a non-existent task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const fn = jest.fn();
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it('cant trigger a running task', async () => {
|
||||
const { manager } = await init('SQLITE_3');
|
||||
|
||||
const { promise, resolve } = defer();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromObject({ years: 1 }),
|
||||
fn: async () => {
|
||||
resolve();
|
||||
await new Promise(r => setTimeout(r, 20000));
|
||||
},
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await promise;
|
||||
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('createScheduledTaskRunner', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the happy path, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
|
||||
const fn = jest.fn();
|
||||
const promise = new Promise(resolve => fn.mockImplementation(resolve));
|
||||
await manager
|
||||
.createScheduledTaskRunner({
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
scope: 'global',
|
||||
})
|
||||
.run({
|
||||
id: 'task1',
|
||||
fn,
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('can fetch task ids', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can fetch both global and local task ids, %p',
|
||||
async databaseId => {
|
||||
const { manager } = await init(databaseId);
|
||||
const fn = jest.fn();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
fn,
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
fn,
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
await expect(manager.getScheduledTasks()).resolves.toEqual([
|
||||
{
|
||||
id: 'task1',
|
||||
scope: 'global',
|
||||
settings: expect.objectContaining({ cadence: 'PT5S' }),
|
||||
},
|
||||
{
|
||||
id: 'task2',
|
||||
scope: 'local',
|
||||
settings: expect.objectContaining({ cadence: 'PT5S' }),
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('parseDuration', () => {
|
||||
it('should parse durations', () => {
|
||||
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
|
||||
expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S');
|
||||
expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *');
|
||||
expect(parseDuration({ trigger: 'manual' })).toEqual('manual');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import { Duration } from 'luxon';
|
||||
import { LocalTaskWorker } from './LocalTaskWorker';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
import {
|
||||
PluginTaskScheduler,
|
||||
TaskDescriptor,
|
||||
TaskFunction,
|
||||
TaskInvocationDefinition,
|
||||
TaskRunner,
|
||||
TaskScheduleDefinition,
|
||||
TaskSettingsV2,
|
||||
} from './types';
|
||||
import { validateId } from './util';
|
||||
import { Counter, Histogram, metrics } from '@opentelemetry/api';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Implements the actual task management.
|
||||
*/
|
||||
export class PluginTaskSchedulerImpl implements PluginTaskScheduler {
|
||||
private readonly localTasksById = new Map<string, LocalTaskWorker>();
|
||||
private readonly allScheduledTasks: TaskDescriptor[] = [];
|
||||
|
||||
private readonly counter: Counter;
|
||||
private readonly duration: Histogram;
|
||||
|
||||
constructor(
|
||||
private readonly databaseFactory: () => Promise<Knex>,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
const meter = metrics.getMeter('default');
|
||||
this.counter = meter.createCounter('backend_tasks.task.runs.count', {
|
||||
description: 'Total number of times a task has been run',
|
||||
});
|
||||
this.duration = meter.createHistogram('backend_tasks.task.runs.duration', {
|
||||
description: 'Histogram of task run durations',
|
||||
unit: 'seconds',
|
||||
});
|
||||
}
|
||||
|
||||
async triggerTask(id: string): Promise<void> {
|
||||
const localTask = this.localTasksById.get(id);
|
||||
if (localTask) {
|
||||
localTask.trigger();
|
||||
return;
|
||||
}
|
||||
|
||||
const knex = await this.databaseFactory();
|
||||
await TaskWorker.trigger(knex, id);
|
||||
}
|
||||
|
||||
async scheduleTask(
|
||||
task: TaskScheduleDefinition & TaskInvocationDefinition,
|
||||
): Promise<void> {
|
||||
validateId(task.id);
|
||||
const scope = task.scope ?? 'global';
|
||||
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: parseDuration(task.frequency),
|
||||
initialDelayDuration:
|
||||
task.initialDelay && parseDuration(task.initialDelay),
|
||||
timeoutAfterDuration: parseDuration(task.timeout),
|
||||
};
|
||||
|
||||
if (scope === 'global') {
|
||||
const knex = await this.databaseFactory();
|
||||
const worker = new TaskWorker(
|
||||
task.id,
|
||||
this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }),
|
||||
knex,
|
||||
this.logger.child({ task: task.id }),
|
||||
);
|
||||
await worker.start(settings, { signal: task.signal });
|
||||
} else {
|
||||
const worker = new LocalTaskWorker(
|
||||
task.id,
|
||||
this.wrapInMetrics(task.fn, { labels: { taskId: task.id, scope } }),
|
||||
this.logger.child({ task: task.id }),
|
||||
);
|
||||
worker.start(settings, { signal: task.signal });
|
||||
this.localTasksById.set(task.id, worker);
|
||||
}
|
||||
|
||||
this.allScheduledTasks.push({
|
||||
id: task.id,
|
||||
scope: scope,
|
||||
settings: settings,
|
||||
});
|
||||
}
|
||||
|
||||
createScheduledTaskRunner(schedule: TaskScheduleDefinition): TaskRunner {
|
||||
return {
|
||||
run: async task => {
|
||||
await this.scheduleTask({ ...task, ...schedule });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getScheduledTasks(): Promise<TaskDescriptor[]> {
|
||||
return this.allScheduledTasks;
|
||||
}
|
||||
|
||||
private wrapInMetrics(
|
||||
fn: TaskFunction,
|
||||
opts: { labels: Record<string, string> },
|
||||
): TaskFunction {
|
||||
return async abort => {
|
||||
const labels = {
|
||||
...opts.labels,
|
||||
};
|
||||
this.counter.add(1, { ...labels, result: 'started' });
|
||||
|
||||
const startTime = process.hrtime();
|
||||
|
||||
try {
|
||||
await fn(abort);
|
||||
labels.result = 'completed';
|
||||
} catch (ex) {
|
||||
labels.result = 'failed';
|
||||
throw ex;
|
||||
} finally {
|
||||
const delta = process.hrtime(startTime);
|
||||
const endTime = delta[0] + delta[1] / 1e9;
|
||||
this.counter.add(1, labels);
|
||||
this.duration.record(endTime, labels);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDuration(
|
||||
frequency: TaskScheduleDefinition['frequency'],
|
||||
): string {
|
||||
if ('cron' in frequency) {
|
||||
return frequency.cron;
|
||||
}
|
||||
if ('trigger' in frequency) {
|
||||
return frequency.trigger;
|
||||
}
|
||||
|
||||
const parsed = Duration.isDuration(frequency)
|
||||
? frequency
|
||||
: Duration.fromObject(frequency);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
throw new Error(
|
||||
`Invalid duration, ${parsed.invalidReason}: ${parsed.invalidExplanation}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed.toISO()!;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabases, mockServices } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
import { Duration } from 'luxon';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
|
||||
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
|
||||
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
|
||||
|
||||
const insertTask = async (knex: Knex, task: DbTasksRow) => {
|
||||
return knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.insert(task)
|
||||
.onConflict('id')
|
||||
.merge(['settings_json']);
|
||||
};
|
||||
|
||||
const getTask = async (knex: Knex): Promise<DbTasksRow> => {
|
||||
return (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
};
|
||||
|
||||
describe('PluginTaskSchedulerJanitor', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create({
|
||||
ids: [
|
||||
/* 'MYSQL_8' not supported yet */
|
||||
'POSTGRES_16',
|
||||
'POSTGRES_12',
|
||||
'SQLITE_3',
|
||||
'MYSQL_8',
|
||||
],
|
||||
});
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'Should update date if current_run_expires_at expires, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const dateYesterday = new Date(
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
);
|
||||
|
||||
await insertTask(knex, {
|
||||
id: 'task1',
|
||||
settings_json: '',
|
||||
next_run_start_at: new Date('2023-03-01 00:00:00'),
|
||||
current_run_ticket: 'ticket',
|
||||
current_run_started_at: dateYesterday,
|
||||
current_run_expires_at: dateYesterday,
|
||||
});
|
||||
|
||||
const worker = new PluginTaskSchedulerJanitor({
|
||||
waitBetweenRuns: Duration.fromObject({ milliseconds: 20 }),
|
||||
knex,
|
||||
logger,
|
||||
});
|
||||
|
||||
worker.start(testScopedSignal());
|
||||
|
||||
await waitForExpect(async () => {
|
||||
await expect(getTask(knex)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import { Duration } from 'luxon';
|
||||
import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables';
|
||||
import { sleep } from './util';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Makes sure to auto-expire and clean up things that time out or for other
|
||||
* reasons should not be left lingering.
|
||||
*/
|
||||
export class PluginTaskSchedulerJanitor {
|
||||
private readonly knex: Knex;
|
||||
private readonly waitBetweenRuns: Duration;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(options: {
|
||||
knex: Knex;
|
||||
waitBetweenRuns: Duration;
|
||||
logger: LoggerService;
|
||||
}) {
|
||||
this.knex = options.knex;
|
||||
this.waitBetweenRuns = options.waitBetweenRuns;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
async start(abortSignal?: AbortSignal) {
|
||||
while (!abortSignal?.aborted) {
|
||||
try {
|
||||
await this.runOnce();
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error while performing janitorial tasks, ${e}`);
|
||||
}
|
||||
|
||||
await sleep(this.waitBetweenRuns, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
private async runOnce() {
|
||||
const dbNull = this.knex.raw('null');
|
||||
const configClient = this.knex.client.config.client;
|
||||
|
||||
let tasks: Array<{ id: string }>;
|
||||
if (configClient.includes('sqlite3') || configClient.includes('mysql')) {
|
||||
tasks = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.select('id')
|
||||
.where('current_run_expires_at', '<', this.knex.fn.now());
|
||||
await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.whereIn(
|
||||
'id',
|
||||
tasks.map(t => t.id),
|
||||
)
|
||||
.update({
|
||||
current_run_ticket: dbNull,
|
||||
current_run_started_at: dbNull,
|
||||
current_run_expires_at: dbNull,
|
||||
});
|
||||
} else {
|
||||
tasks = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('current_run_expires_at', '<', this.knex.fn.now())
|
||||
.update({
|
||||
current_run_ticket: dbNull,
|
||||
current_run_started_at: dbNull,
|
||||
current_run_expires_at: dbNull,
|
||||
})
|
||||
.returning(['id']);
|
||||
}
|
||||
|
||||
// In rare cases, knex drivers may ignore "returning", and return the number
|
||||
// of rows changed instead
|
||||
if (typeof tasks === 'number') {
|
||||
if (tasks > 0) {
|
||||
this.logger.warn(`${tasks} tasks timed out and were lost`);
|
||||
}
|
||||
} else {
|
||||
for (const { id } of tasks) {
|
||||
this.logger.warn(`Task timed out and was lost: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DatabaseManager } from '@backstage/backend-common';
|
||||
import {
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
mockServices,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { Duration } from 'luxon';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
import { TaskScheduler } from './TaskScheduler';
|
||||
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TaskScheduler', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
): Promise<DatabaseManager> {
|
||||
const knex = await databases.init(databaseId);
|
||||
const databaseManager: Partial<DatabaseManager> = {
|
||||
forPlugin: () => ({
|
||||
getClient: async () => knex,
|
||||
}),
|
||||
};
|
||||
return databaseManager as DatabaseManager;
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can return a working v1 plugin impl, %p',
|
||||
async databaseId => {
|
||||
const database = await createDatabase(databaseId);
|
||||
const manager = new TaskScheduler(database, logger).forPlugin('test');
|
||||
const fn = jest.fn();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task1',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: Duration.fromMillis(5000),
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can return a working v2 plugin impl, %p',
|
||||
async databaseId => {
|
||||
const database = await createDatabase(databaseId);
|
||||
const manager = new TaskScheduler(database, logger).forPlugin('test');
|
||||
const fn = jest.fn();
|
||||
|
||||
await manager.scheduleTask({
|
||||
id: 'task2',
|
||||
timeout: Duration.fromMillis(5000),
|
||||
frequency: { cron: '* * * * * *' },
|
||||
signal: testScopedSignal(),
|
||||
fn,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
DatabaseManager,
|
||||
getRootLogger,
|
||||
LegacyRootDatabaseService,
|
||||
PluginDatabaseManager,
|
||||
} from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { once } from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
|
||||
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
|
||||
import { PluginTaskScheduler } from './types';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Deals with the scheduling of distributed tasks.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Please migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead, or use `DefaultSchedulerService` from `@backstage/backend-defaults`
|
||||
*/
|
||||
export class TaskScheduler {
|
||||
/**
|
||||
* @deprecated
|
||||
* It is only used by the legacy backend system, and should not be used in the new backend system.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
databaseManager?: LegacyRootDatabaseService;
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): TaskScheduler {
|
||||
const databaseManager =
|
||||
options?.databaseManager ?? DatabaseManager.fromConfig(config);
|
||||
const logger = (options?.logger || getRootLogger()).child({
|
||||
type: 'taskManager',
|
||||
});
|
||||
return new TaskScheduler(databaseManager, logger);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly databaseManager: LegacyRootDatabaseService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Instantiates a task manager instance for the given plugin.
|
||||
*
|
||||
* @param pluginId - The unique ID of the plugin, for example "catalog"
|
||||
* @returns A {@link PluginTaskScheduler} instance
|
||||
* @deprecated Please migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead, or use `DefaultSchedulerService` from `@backstage/backend-defaults`
|
||||
*/
|
||||
forPlugin(pluginId: string): PluginTaskScheduler {
|
||||
return TaskScheduler.forPlugin({
|
||||
pluginId,
|
||||
databaseManager: this.databaseManager.forPlugin(pluginId),
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please migrate to the new backend system, and depend on `coreServices.scheduler` from `@backstage/backend-plugin-api` instead, or use `DefaultSchedulerService` from `@backstage/backend-defaults`
|
||||
*/
|
||||
static forPlugin(opts: {
|
||||
pluginId: string;
|
||||
databaseManager: PluginDatabaseManager;
|
||||
logger: LoggerService;
|
||||
}): PluginTaskScheduler {
|
||||
const databaseFactory = once(async () => {
|
||||
const knex = await opts.databaseManager.getClient();
|
||||
|
||||
if (!opts.databaseManager.migrations?.skip) {
|
||||
await migrateBackendTasks(knex);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const janitor = new PluginTaskSchedulerJanitor({
|
||||
knex,
|
||||
waitBetweenRuns: Duration.fromObject({ minutes: 1 }),
|
||||
logger: opts.logger,
|
||||
});
|
||||
janitor.start();
|
||||
}
|
||||
|
||||
return knex;
|
||||
});
|
||||
|
||||
return new PluginTaskSchedulerImpl(databaseFactory, opts.logger);
|
||||
}
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabases, mockServices } from '@backstage/backend-test-utils';
|
||||
import { Duration, DateTime } from 'luxon';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
import { TaskSettingsV2 } from './types';
|
||||
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TaskWorker', () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const testScopedSignal = createTestScopedSignal();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'goes through the expected states, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: '*/2 * * * * *',
|
||||
initialDelayDuration: Duration.fromObject({ seconds: 1 }).toISO()!,
|
||||
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!,
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task1', fn, knex, logger);
|
||||
await worker.persistTask(settings);
|
||||
|
||||
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(row.settings_json)).toEqual({
|
||||
version: 2,
|
||||
cadence: '*/2 * * * * *',
|
||||
initialDelayDuration: 'PT1S',
|
||||
timeoutAfterDuration: 'PT1M',
|
||||
});
|
||||
|
||||
await expect(worker.findReadyTask()).resolves.toEqual({
|
||||
result: 'not-ready-yet',
|
||||
});
|
||||
|
||||
await waitForExpect(async () => {
|
||||
await expect(worker.findReadyTask()).resolves.toEqual({
|
||||
result: 'ready',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true);
|
||||
|
||||
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: 'ticket',
|
||||
current_run_started_at: expect.anything(),
|
||||
current_run_expires_at: expect.anything(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: null,
|
||||
current_run_started_at: null,
|
||||
current_run_expires_at: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'logs error when the task throws, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
jest.spyOn(logger, 'error');
|
||||
const fn = jest.fn().mockRejectedValue(new Error('failed'));
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
initialDelayDuration: undefined,
|
||||
cadence: '* * * * * *',
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO()!,
|
||||
};
|
||||
const checkFrequency = Duration.fromObject({ milliseconds: 100 });
|
||||
const worker = new TaskWorker('task1', fn, knex, logger, checkFrequency);
|
||||
worker.start(settings, { signal: testScopedSignal() });
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'runs tasks more than once even when the task throws, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn().mockRejectedValue(new Error('failed'));
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
initialDelayDuration: undefined,
|
||||
cadence: '* * * * * *',
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO()!,
|
||||
};
|
||||
const checkFrequency = Duration.fromObject({ milliseconds: 100 });
|
||||
const worker = new TaskWorker('task1', fn, knex, logger, checkFrequency);
|
||||
worker.start(settings, { signal: testScopedSignal() });
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'does not clobber ticket lock when stolen, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
initialDelayDuration: undefined,
|
||||
cadence: '* * * * * *',
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO()!,
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task1', fn, knex, logger);
|
||||
await worker.persistTask(settings);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
await expect(worker.findReadyTask()).resolves.toEqual({
|
||||
result: 'ready',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true);
|
||||
|
||||
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: 'ticket',
|
||||
current_run_started_at: expect.anything(),
|
||||
current_run_expires_at: expect.anything(),
|
||||
}),
|
||||
);
|
||||
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('id', '=', 'task1')
|
||||
.update({ current_run_ticket: 'stolen' });
|
||||
|
||||
await expect(worker.tryReleaseTask('ticket', settings)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'task1',
|
||||
current_run_ticket: 'stolen',
|
||||
current_run_started_at: expect.anything(),
|
||||
current_run_expires_at: expect.anything(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'gracefully handles a disappeared task row, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(async () => {});
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
initialDelayDuration: undefined,
|
||||
cadence: '* * * * * *',
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO()!,
|
||||
};
|
||||
|
||||
const worker1 = new TaskWorker('task1', fn, knex, logger);
|
||||
await worker1.persistTask(settings);
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task1').delete();
|
||||
await expect(worker1.findReadyTask()).resolves.toEqual({
|
||||
result: 'abort',
|
||||
});
|
||||
|
||||
const worker2 = new TaskWorker('task2', fn, knex, logger);
|
||||
await worker2.persistTask(settings);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
await expect(worker2.findReadyTask()).resolves.toEqual({
|
||||
result: 'ready',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task2').delete();
|
||||
await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const worker3 = new TaskWorker('task3', fn, knex, logger);
|
||||
await worker3.persistTask(settings);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
await expect(worker3.findReadyTask()).resolves.toEqual({
|
||||
result: 'ready',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE).where('id', '=', 'task3').delete();
|
||||
await expect(worker3.tryReleaseTask('ticket', settings)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'respects initialDelayDuration per worker, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const abortFirst = new AbortController();
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
initialDelayDuration: 'PT0.3S',
|
||||
cadence: 'PT0.1S',
|
||||
timeoutAfterDuration: 'PT10S',
|
||||
};
|
||||
|
||||
// Start a single worker and make sure it waits and then goes to work
|
||||
const fn1 = jest.fn(async () => {});
|
||||
const worker1 = new TaskWorker(
|
||||
'task1',
|
||||
fn1,
|
||||
knex,
|
||||
logger,
|
||||
Duration.fromMillis(10),
|
||||
);
|
||||
await worker1.start(settings, { signal: abortFirst.signal });
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(0);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(fn1).toHaveBeenCalledTimes(0);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(fn1.mock.calls.length).toBeGreaterThan(0);
|
||||
|
||||
// Start a second worker and make sure it waits but the first worker still works along
|
||||
const fn2 = jest.fn();
|
||||
const promise2 = new Promise(resolve => fn2.mockImplementation(resolve));
|
||||
const worker2 = new TaskWorker(
|
||||
'task1',
|
||||
fn2,
|
||||
knex,
|
||||
logger,
|
||||
Duration.fromMillis(10),
|
||||
);
|
||||
await worker2.start(settings, { signal: testScopedSignal() });
|
||||
|
||||
// We eventually abort the first worker just to make sure that the second
|
||||
// one for sure will get a go at running the task
|
||||
setTimeout(() => abortFirst.abort(), 1000);
|
||||
|
||||
const before = fn1.mock.calls.length;
|
||||
await promise2;
|
||||
expect(fn1.mock.calls.length).toBeGreaterThan(before);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'next_run_start_at is always the min between schedule changes from cron frequency, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
const settings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: '*/15 * * * *',
|
||||
initialDelayDuration: 'PT2M',
|
||||
timeoutAfterDuration: 'PT1M',
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task99', fn, knex, logger);
|
||||
await worker.persistTask(settings);
|
||||
const row1 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
const settings2 = {
|
||||
...settings,
|
||||
cadence: '*/2 * * * *',
|
||||
initialDelayDuration: 'PT1M',
|
||||
};
|
||||
await worker.persistTask(settings2);
|
||||
const row2 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
expect(row2.next_run_start_at).not.toStrictEqual(row1.next_run_start_at);
|
||||
|
||||
const settings3 = { ...settings };
|
||||
await worker.persistTask(settings3);
|
||||
const row3 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
// The new timestamp can basically be 0 or a minute depending on how the
|
||||
// initialDelayDuration falls right on a cron boundary. This kinda
|
||||
// contrived check removes a test flakiness based on wall clock time.
|
||||
expect(
|
||||
Math.abs(
|
||||
+new Date(row3.next_run_start_at) - +new Date(row2.next_run_start_at),
|
||||
),
|
||||
).toBeLessThanOrEqual(60_000);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'next_run_start_at is always the min between schedule changes when using human duration frequency, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
|
||||
const initialSettings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: 'PT120M',
|
||||
timeoutAfterDuration: 'PT1M',
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task99', fn, knex, logger);
|
||||
await worker.persistTask(initialSettings);
|
||||
// replicate task running, sets next_run_start_at based on cadence
|
||||
await worker.tryClaimTask('ticket', initialSettings);
|
||||
await worker.tryReleaseTask('ticket', initialSettings);
|
||||
|
||||
// grab initial row for comparisons later
|
||||
const rowAfterClaimAndRelease = (
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
)[0];
|
||||
|
||||
const settings: TaskSettingsV2 = {
|
||||
...initialSettings,
|
||||
cadence: 'PT60M',
|
||||
};
|
||||
await worker.persistTask(settings);
|
||||
const row1 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
const rowAfterClaimAndReleaseNextStartAt = DateTime.fromJSDate(
|
||||
new Date(rowAfterClaimAndRelease.next_run_start_at),
|
||||
);
|
||||
const row1NextStartAt = DateTime.fromJSDate(
|
||||
new Date(row1.next_run_start_at),
|
||||
);
|
||||
const now = DateTime.now();
|
||||
expect(
|
||||
rowAfterClaimAndReleaseNextStartAt.diff(row1NextStartAt).as('minutes'),
|
||||
).toBeCloseTo(60, 1); // ensure that next start at is sooner than initial by one hour
|
||||
expect(row1NextStartAt.diff(now).as('minutes')).toBeCloseTo(60, 1); // ensure that next start at is later than now by one hour
|
||||
expect(
|
||||
rowAfterClaimAndReleaseNextStartAt.diff(now).as('minutes'),
|
||||
).toBeCloseTo(120, 1);
|
||||
|
||||
const settings2 = {
|
||||
...settings,
|
||||
};
|
||||
await worker.persistTask(settings2);
|
||||
const row2 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
expect(row2.next_run_start_at).toStrictEqual(row1.next_run_start_at);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'next_run_start_at is always the min between schedule changes when using human duration frequency with initial start delay, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
|
||||
const initialSettings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: 'PT120M',
|
||||
initialDelayDuration: 'PT2M',
|
||||
timeoutAfterDuration: 'PT1M',
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task99', fn, knex, logger);
|
||||
await worker.persistTask(initialSettings);
|
||||
// replicate task running, sets next_run_start_at based on cadence
|
||||
await worker.tryClaimTask('ticket', initialSettings);
|
||||
await worker.tryReleaseTask('ticket', initialSettings);
|
||||
|
||||
// grab initial row for comparisons later
|
||||
const rowAfterClaimAndRelease = (
|
||||
await knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
)[0];
|
||||
|
||||
const settings: TaskSettingsV2 = {
|
||||
...initialSettings,
|
||||
cadence: 'PT60M',
|
||||
};
|
||||
await worker.persistTask(settings);
|
||||
const row1 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
const rowAfterClaimAndReleaseNextStartAt = DateTime.fromJSDate(
|
||||
new Date(rowAfterClaimAndRelease.next_run_start_at),
|
||||
);
|
||||
const row1NextStartAt = DateTime.fromJSDate(
|
||||
new Date(row1.next_run_start_at),
|
||||
);
|
||||
const now = DateTime.now();
|
||||
expect(
|
||||
rowAfterClaimAndReleaseNextStartAt.diff(row1NextStartAt).as('minutes'),
|
||||
).toBeCloseTo(62, 1); // ensure that next start at is sooner than initial by one hour, plus the 2 minute delay (set my tryReleaseTask)
|
||||
expect(row1NextStartAt.diff(now).as('minutes')).toBeCloseTo(60, 1); // ensure that next start at is later than now by one hour (2 minute delay doesn't take effect here)
|
||||
expect(
|
||||
rowAfterClaimAndReleaseNextStartAt.diff(now).as('minutes'),
|
||||
).toBeCloseTo(122, 1); // includes 2 minute start delay (which is persisted from tryReleaseTask)
|
||||
|
||||
const settings2 = {
|
||||
...settings,
|
||||
};
|
||||
await worker.persistTask(settings2);
|
||||
const row2 = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
|
||||
expect(row2.next_run_start_at).toStrictEqual(row1.next_run_start_at);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'next_run_start_at is not set for manually-triggered tasks, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const fn = jest.fn(
|
||||
async () => new Promise<void>(resolve => setTimeout(resolve, 50)),
|
||||
);
|
||||
|
||||
const initialSettings: TaskSettingsV2 = {
|
||||
version: 2,
|
||||
cadence: 'manual',
|
||||
timeoutAfterDuration: 'PT1M',
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task99', fn, knex, logger);
|
||||
await worker.persistTask(initialSettings);
|
||||
await worker.tryClaimTask('ticket', initialSettings);
|
||||
await worker.tryReleaseTask('ticket', initialSettings);
|
||||
|
||||
const row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
|
||||
expect(row.next_run_start_at).toBeNull();
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { CronTime } from 'cron';
|
||||
import { Knex } from 'knex';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DB_TASKS_TABLE, DbTasksRow } from '../database/tables';
|
||||
import { TaskFunction, TaskSettingsV2, taskSettingsV2Schema } from './types';
|
||||
import { delegateAbortController, nowPlus, sleep } from './util';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
const DEFAULT_WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 });
|
||||
|
||||
/**
|
||||
* Implements tasks that run across worker hosts, with collaborative locking.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export class TaskWorker {
|
||||
constructor(
|
||||
private readonly taskId: string,
|
||||
private readonly fn: TaskFunction,
|
||||
private readonly knex: Knex,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly workCheckFrequency: Duration = DEFAULT_WORK_CHECK_FREQUENCY,
|
||||
) {}
|
||||
|
||||
async start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) {
|
||||
try {
|
||||
await this.persistTask(settings);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to persist task, ${e}`);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`,
|
||||
);
|
||||
|
||||
let workCheckFrequency = this.workCheckFrequency;
|
||||
const isDuration = settings?.cadence.startsWith('P');
|
||||
if (isDuration) {
|
||||
const cadence = Duration.fromISO(settings.cadence);
|
||||
if (cadence < workCheckFrequency) {
|
||||
workCheckFrequency = cadence;
|
||||
}
|
||||
}
|
||||
|
||||
let attemptNum = 1;
|
||||
(async () => {
|
||||
for (;;) {
|
||||
try {
|
||||
if (settings.initialDelayDuration) {
|
||||
await sleep(
|
||||
Duration.fromISO(settings.initialDelayDuration),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
while (!options?.signal?.aborted) {
|
||||
const runResult = await this.runOnce(options?.signal);
|
||||
|
||||
if (runResult.result === 'abort') {
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(workCheckFrequency, options?.signal);
|
||||
}
|
||||
|
||||
this.logger.info(`Task worker finished: ${this.taskId}`);
|
||||
attemptNum = 0;
|
||||
break;
|
||||
} catch (e) {
|
||||
attemptNum += 1;
|
||||
this.logger.warn(
|
||||
`Task worker failed unexpectedly, attempt number ${attemptNum}, ${e}`,
|
||||
);
|
||||
await sleep(Duration.fromObject({ seconds: 1 }));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
static async trigger(knex: Knex, taskId: string): Promise<void> {
|
||||
// check if task exists
|
||||
const rows = await knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.select(knex.raw(1))
|
||||
.where('id', '=', taskId);
|
||||
if (rows.length !== 1) {
|
||||
throw new NotFoundError(`Task ${taskId} does not exist`);
|
||||
}
|
||||
|
||||
const updatedRows = await knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('id', '=', taskId)
|
||||
.whereNull('current_run_ticket')
|
||||
.update({
|
||||
next_run_start_at: knex.fn.now(),
|
||||
});
|
||||
if (updatedRows < 1) {
|
||||
throw new ConflictError(`Task ${taskId} is currently running`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a single attempt at running the task to completion, if ready.
|
||||
*
|
||||
* @returns The outcome of the attempt
|
||||
*/
|
||||
private async runOnce(
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { result: 'not-ready-yet' }
|
||||
| { result: 'abort' }
|
||||
| { result: 'failed' }
|
||||
| { result: 'completed' }
|
||||
> {
|
||||
const findResult = await this.findReadyTask();
|
||||
if (
|
||||
findResult.result === 'not-ready-yet' ||
|
||||
findResult.result === 'abort'
|
||||
) {
|
||||
return findResult;
|
||||
}
|
||||
|
||||
const taskSettings = findResult.settings;
|
||||
const ticket = uuid();
|
||||
|
||||
const claimed = await this.tryClaimTask(ticket, taskSettings);
|
||||
if (!claimed) {
|
||||
return { result: 'not-ready-yet' };
|
||||
}
|
||||
|
||||
// Abort the task execution either if the worker is stopped, or if the
|
||||
// task timeout is hit
|
||||
const taskAbortController = delegateAbortController(signal);
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
taskAbortController.abort();
|
||||
}, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds'));
|
||||
|
||||
try {
|
||||
await this.fn(taskAbortController.signal);
|
||||
taskAbortController.abort(); // releases resources
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
await this.tryReleaseTask(ticket, taskSettings);
|
||||
return { result: 'failed' };
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
await this.tryReleaseTask(ticket, taskSettings);
|
||||
return { result: 'completed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the initial store of the task info
|
||||
*/
|
||||
async persistTask(settings: TaskSettingsV2) {
|
||||
// Perform an initial parse to ensure that we will definitely be able to
|
||||
// read it back again.
|
||||
taskSettingsV2Schema.parse(settings);
|
||||
|
||||
const isManual = settings?.cadence === 'manual';
|
||||
const isDuration = settings?.cadence.startsWith('P');
|
||||
const isCron = !isManual && !isDuration;
|
||||
|
||||
let startAt: Knex.Raw | undefined;
|
||||
let nextStartAt: Knex.Raw | undefined;
|
||||
if (settings.initialDelayDuration) {
|
||||
startAt = nowPlus(
|
||||
Duration.fromISO(settings.initialDelayDuration),
|
||||
this.knex,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCron) {
|
||||
const time = new CronTime(settings.cadence)
|
||||
.sendAt()
|
||||
.minus({ seconds: 1 }) // immediately, if "* * * * * *"
|
||||
.toUTC();
|
||||
|
||||
nextStartAt = this.nextRunAtRaw(time);
|
||||
startAt ||= nextStartAt;
|
||||
} else if (isManual) {
|
||||
nextStartAt = this.knex.raw('null');
|
||||
startAt ||= nextStartAt;
|
||||
} else {
|
||||
startAt ||= this.knex.fn.now();
|
||||
nextStartAt = nowPlus(Duration.fromISO(settings.cadence), this.knex);
|
||||
}
|
||||
|
||||
this.logger.debug(`task: ${this.taskId} configured to run at: ${startAt}`);
|
||||
|
||||
// It's OK if the task already exists; if it does, just replace its
|
||||
// settings with the new value and start the loop as usual.
|
||||
const settingsJson = JSON.stringify(settings);
|
||||
await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.insert({
|
||||
id: this.taskId,
|
||||
settings_json: settingsJson,
|
||||
next_run_start_at: startAt,
|
||||
})
|
||||
.onConflict('id')
|
||||
.merge(
|
||||
this.knex.client.config.client.includes('mysql')
|
||||
? {
|
||||
settings_json: settingsJson,
|
||||
next_run_start_at: this.knex.raw(
|
||||
`CASE WHEN ?? < ?? THEN ?? ELSE ?? END`,
|
||||
[
|
||||
nextStartAt,
|
||||
'next_run_start_at',
|
||||
nextStartAt,
|
||||
'next_run_start_at',
|
||||
],
|
||||
),
|
||||
}
|
||||
: {
|
||||
settings_json: this.knex.ref('excluded.settings_json'),
|
||||
next_run_start_at: this.knex.raw(
|
||||
`CASE WHEN ?? < ?? THEN ?? ELSE ?? END`,
|
||||
[
|
||||
nextStartAt,
|
||||
`${DB_TASKS_TABLE}.next_run_start_at`,
|
||||
nextStartAt,
|
||||
`${DB_TASKS_TABLE}.next_run_start_at`,
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the task is ready to run
|
||||
*/
|
||||
async findReadyTask(): Promise<
|
||||
| { result: 'not-ready-yet' }
|
||||
| { result: 'abort' }
|
||||
| { result: 'ready'; settings: TaskSettingsV2 }
|
||||
> {
|
||||
const [row] = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('id', '=', this.taskId)
|
||||
.select({
|
||||
settingsJson: 'settings_json',
|
||||
ready: this.knex.raw(
|
||||
`CASE
|
||||
WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE
|
||||
ELSE FALSE
|
||||
END`,
|
||||
[this.knex.fn.now()],
|
||||
),
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
this.logger.info(
|
||||
'No longer able to find task; aborting and assuming that it has been unregistered or expired',
|
||||
);
|
||||
return { result: 'abort' };
|
||||
} else if (!row.ready) {
|
||||
return { result: 'not-ready-yet' };
|
||||
}
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(row.settingsJson);
|
||||
const settings = taskSettingsV2Schema.parse(obj);
|
||||
return { result: 'ready', settings };
|
||||
} catch (e) {
|
||||
this.logger.info(
|
||||
`Task "${this.taskId}" is no longer able to parse task settings; aborting and assuming that a ` +
|
||||
`newer version of the task has been issued and being handled by other workers, ${e}`,
|
||||
);
|
||||
return { result: 'abort' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to claim a task that's ready for execution, on this worker's
|
||||
* behalf. We should not attempt to perform the work unless the claim really
|
||||
* goes through.
|
||||
*
|
||||
* @param ticket - A globally unique string that changes for each invocation
|
||||
* @param settings - The settings of the task to claim
|
||||
* @returns True if it was successfully claimed
|
||||
*/
|
||||
async tryClaimTask(
|
||||
ticket: string,
|
||||
settings: TaskSettingsV2,
|
||||
): Promise<boolean> {
|
||||
const startedAt = this.knex.fn.now();
|
||||
const expiresAt = settings.timeoutAfterDuration
|
||||
? nowPlus(Duration.fromISO(settings.timeoutAfterDuration), this.knex)
|
||||
: this.knex.raw('null');
|
||||
|
||||
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('id', '=', this.taskId)
|
||||
.whereNull('current_run_ticket')
|
||||
.update({
|
||||
current_run_ticket: ticket,
|
||||
current_run_started_at: startedAt,
|
||||
current_run_expires_at: expiresAt,
|
||||
});
|
||||
|
||||
return rows === 1;
|
||||
}
|
||||
|
||||
async tryReleaseTask(
|
||||
ticket: string,
|
||||
settings: TaskSettingsV2,
|
||||
): Promise<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}`);
|
||||
|
||||
nextRun = this.nextRunAtRaw(time);
|
||||
} 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 rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.where('id', '=', this.taskId)
|
||||
.where('current_run_ticket', '=', ticket)
|
||||
.update({
|
||||
next_run_start_at: nextRun,
|
||||
current_run_ticket: this.knex.raw('null'),
|
||||
current_run_started_at: this.knex.raw('null'),
|
||||
current_run_expires_at: this.knex.raw('null'),
|
||||
});
|
||||
|
||||
return rows === 1;
|
||||
}
|
||||
|
||||
private nextRunAtRaw(time: DateTime): Knex.Raw {
|
||||
if (this.knex.client.config.client.includes('sqlite3')) {
|
||||
return this.knex.raw('datetime(?)', [time.toISO()]);
|
||||
} else if (this.knex.client.config.client.includes('mysql')) {
|
||||
return this.knex.raw(`?`, [time.toSQL({ includeOffset: false })]);
|
||||
}
|
||||
return this.knex.raw(`?`, [time.toISO()]);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { readTaskScheduleDefinitionFromConfig } from './readTaskScheduleDefinitionFromConfig';
|
||||
|
||||
describe('readTaskScheduleDefinitionFromConfig', () => {
|
||||
it('all valid values', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
cron: '0 30 * * * *',
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
initialDelay: {
|
||||
minutes: 20,
|
||||
},
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
const result = readTaskScheduleDefinitionFromConfig(config);
|
||||
|
||||
expect((result.frequency as { cron: string }).cron).toBe('0 30 * * * *');
|
||||
expect(result.timeout).toEqual({ minutes: 3 });
|
||||
expect((result.initialDelay as HumanDuration).minutes).toEqual(20);
|
||||
expect(result.scope).toBe('global');
|
||||
});
|
||||
|
||||
it('all valid required values', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
cron: '0 30 * * * *',
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
});
|
||||
|
||||
const result = readTaskScheduleDefinitionFromConfig(config);
|
||||
|
||||
expect((result.frequency as { cron: string }).cron).toBe('0 30 * * * *');
|
||||
expect(result.timeout).toEqual({ minutes: 3 });
|
||||
expect(result.initialDelay).toBeUndefined();
|
||||
expect(result.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fail without required frequency', () => {
|
||||
const config = new ConfigReader({
|
||||
timeout: 'PT3M',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
"Missing required config value at 'frequency'",
|
||||
);
|
||||
});
|
||||
|
||||
it('fail without required timeout', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: 'PT30M',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
"Missing required config value at 'timeout'",
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid frequency key', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
invalid: 'value',
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
"Failed to read duration from config at 'frequency', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'",
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid frequency value', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
minutes: 'value',
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
"Failed to read duration from config, Error: Unable to convert config value for key 'frequency.minutes' in 'mock-config' to a number",
|
||||
);
|
||||
});
|
||||
|
||||
it('frequency value with additional invalid prop', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
minutes: 20,
|
||||
invalid: 'value',
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
"Failed to read duration from config at 'frequency', Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'",
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid scope value', () => {
|
||||
const config = new ConfigReader({
|
||||
frequency: {
|
||||
years: 2,
|
||||
},
|
||||
timeout: 'PT3M',
|
||||
scope: 'invalid',
|
||||
});
|
||||
|
||||
expect(() => readTaskScheduleDefinitionFromConfig(config)).toThrow(
|
||||
'Only "global" or "local" are allowed for TaskScheduleDefinition.scope, but got: invalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user