Merge branch 'master' into mobile-sidebar
Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/dev-utils': patch
|
||||
---
|
||||
|
||||
Add theme switcher to sidebar of dev app.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is
|
||||
duplicated in the tabs above.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-client': patch
|
||||
---
|
||||
|
||||
Update to the right version of @backstage/errors
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
'@backstage/plugin-gcp-projects': patch
|
||||
---
|
||||
|
||||
UI updates to GCP-projects plugin
|
||||
|
||||
Adds the following to the project list page:
|
||||
|
||||
- pagination
|
||||
- filtering
|
||||
- sorting
|
||||
- rows per page
|
||||
- show/hide columns
|
||||
|
||||
Makes breadcrumb a link back to project list for the project details and new project views.
|
||||
|
||||
In project list page, updates New project button to use RouterLink instead of `href` to avoid login prompt.
|
||||
|
||||
In project details view, links to project details and logs now work, clicking on these will open the project or logs in GCP in new tab.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/cli': patch
|
||||
'@backstage/config-loader': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Update the json-schema dependency version.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
remove double config dep
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/dev-utils': patch
|
||||
---
|
||||
|
||||
Migrated to using `@backstage/app-defaults`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added the `isDatabaseConflictError` function.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Migrated the app template use the new `@backstage/app-defaults` for the `createApp` import, since the `createApp` exported by `@backstage/app-core-api` will be removed in the future.
|
||||
|
||||
To migrate an existing application, add the latest version of `@backstage/app-defaults` as a dependency in `packages/app/package.json`, and make the following change to `packages/app/src/App.tsx`:
|
||||
|
||||
```diff
|
||||
-import { createApp, FlatRoutes } from '@backstage/core-app-api';
|
||||
+import { createApp } from '@backstage/app-defaults';
|
||||
+import { FlatRoutes } from '@backstage/core-app-api';
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-user-settings': patch
|
||||
---
|
||||
|
||||
Add Props Icon for Sidebar Item SidebarSearchField and Settings
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Refactor and add regression tests for create-app tasks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': minor
|
||||
---
|
||||
|
||||
Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Skip empty file names when scaffolding with nunjucks
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely.
|
||||
|
||||
After this change, the backend logger will be able to actually capture the error as it happens:
|
||||
|
||||
```
|
||||
2021-11-11T10:54:21.334Z backstage info Initializing http server
|
||||
2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
Removed the unused `UserFlags` type.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Deprecated the `theme` property on `AppTheme`, replacing it with `Provider`. See https://backstage.io/docs/api/deprecations#app-theme for more details.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Migrated to using `createSpecializedApp`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-azure-devops': patch
|
||||
'@backstage/plugin-azure-devops-backend': patch
|
||||
'@backstage/plugin-azure-devops-common': minor
|
||||
---
|
||||
|
||||
Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement.
|
||||
|
||||
The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`.
|
||||
|
||||
The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
adds getDefaultProcessor method to CatalogBuilder
|
||||
@@ -52,6 +52,7 @@ configmaps
|
||||
configs
|
||||
const
|
||||
cookiecutter
|
||||
cron
|
||||
css
|
||||
Datadog
|
||||
dataflow
|
||||
@@ -162,6 +163,8 @@ Monorepo
|
||||
monorepos
|
||||
msgraph
|
||||
msw
|
||||
mutex
|
||||
mutexes
|
||||
mysql
|
||||
namespace
|
||||
namespaced
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ through the proxy.
|
||||
Learn more about [the different components](overview/what-is-backstage.md) that
|
||||
make up Backstage.
|
||||
|
||||
### Why can't I dynamically install plugins without modifications the app?
|
||||
### Why can't I dynamically install plugins without modifications to the app?
|
||||
|
||||
This decision is part of the core architecture and development flow of
|
||||
Backstage. Plugins have a lot of freedom in what they provide and how they are
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: deprecations
|
||||
title: Deprecations
|
||||
description: A list of active and past deprecations
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
This page contains extended documentation for some of the deprecations in
|
||||
various parts of Backstage. It is not an exhaustive list as most deprecation
|
||||
only come in the form of a changelog notice and a console warning. The
|
||||
deprecations listed here are the ones that need a bit more guidance than what
|
||||
fits in a console message.
|
||||
|
||||
### App Theme
|
||||
|
||||
`Released 2021-11-12 in @backstage/core-plugin-api v0.1.13`
|
||||
|
||||
In order to provide more flexibility in what types of themes can be used and how
|
||||
they are applied, the `theme` property on the `AppTheme` type is being
|
||||
deprecated and replaced by a `Provider` property instead. The `Provider`
|
||||
property is a React component that will be mounted at the root of the app
|
||||
whenever that theme is active. This also removes the tight connection to MUI and
|
||||
opens up for other type of themes, and removes the hardcoded usage of
|
||||
`<CssBaseline>`.
|
||||
|
||||
To migrate an existing theme, remove the `theme` property and move it over to a
|
||||
new `Provider` component, using `ThemeProvider` from MUI to provide the new
|
||||
theme, along with `<CssBaseline>`. For example a theme that currently looks like
|
||||
this:
|
||||
|
||||
```tsx
|
||||
const darkTheme = {
|
||||
id: 'dark',
|
||||
title: 'Dark Theme',
|
||||
variant: 'dark',
|
||||
icon: <DarkIcon />,
|
||||
theme: darkTheme,
|
||||
};
|
||||
```
|
||||
|
||||
Would be migrated to the following:
|
||||
|
||||
```tsx
|
||||
const darkTheme = {
|
||||
id: 'dark',
|
||||
title: 'Dark Theme',
|
||||
variant: 'dark',
|
||||
icon: <DarkIcon />,
|
||||
Provider: ({ children }) => (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
),
|
||||
};
|
||||
```
|
||||
@@ -256,7 +256,8 @@
|
||||
"type": "subcategory",
|
||||
"label": "API Reference",
|
||||
"ids": ["reference/index"]
|
||||
}
|
||||
},
|
||||
"api/deprecations"
|
||||
],
|
||||
"Tutorials": [
|
||||
"tutorials/journey",
|
||||
|
||||
@@ -164,6 +164,7 @@ nav:
|
||||
- API Reference:
|
||||
- Guides:
|
||||
- Utility APIs: 'api/utility-apis.md'
|
||||
- Deprecations: 'api/deprecations.md'
|
||||
- Tutorials:
|
||||
- Future developer journey: 'tutorials/journey.md'
|
||||
- Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md'
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@microsoft/api-documenter": "^7.13.47",
|
||||
"@microsoft/api-documenter": "^7.13.68",
|
||||
"@microsoft/api-extractor": "^7.18.7",
|
||||
"@microsoft/api-extractor-model": "^7.13.5",
|
||||
"@microsoft/tsdoc": "^0.13.2"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
# @backstage/app-defaults
|
||||
|
||||
This package provides a default wiring of a Backstage app that avoids boilerplate when setting up a standard Backstage app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package via Yarn:
|
||||
|
||||
```sh
|
||||
cd packages/app
|
||||
yarn add @backstage/app-defaults
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://backstage.io/docs)
|
||||
@@ -0,0 +1,26 @@
|
||||
## API Report File for "@backstage/app-defaults"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AppComponents } from '@backstage/core-app-api';
|
||||
import { AppIcons } from '@backstage/core-app-api';
|
||||
import { AppOptions } from '@backstage/core-app-api';
|
||||
import { AppTheme } from '@backstage/core-plugin-api';
|
||||
import { BackstageApp } from '@backstage/core-app-api';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
export function createApp(
|
||||
options?: Omit<AppOptions, keyof OptionalAppOptions> & OptionalAppOptions,
|
||||
): BackstageApp;
|
||||
|
||||
// @public
|
||||
export type OptionalAppOptions = {
|
||||
icons?: Partial<AppIcons> & {
|
||||
[key in string]: IconComponent;
|
||||
};
|
||||
themes?: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[];
|
||||
components?: Partial<AppComponents>;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@backstage/app-defaults",
|
||||
"description": "Provides the default wiring of a Backstage App",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/app-defaults"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "backstage-cli build --outputs types,esm",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "^0.7.3",
|
||||
"@backstage/core-app-api": "^0.1.20",
|
||||
"@backstage/core-plugin-api": "^0.1.13",
|
||||
"@backstage/theme": "^0.2.13",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"react": "^16.12.0",
|
||||
"react-router-dom": "6.0.0-beta.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.2",
|
||||
"@backstage/test-utils": "^0.1.21",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react": "*"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import { renderWithEffects } from '@backstage/test-utils';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { createApp } from './createApp';
|
||||
|
||||
describe('Optional ThemeProvider', () => {
|
||||
it('should render app with user-provided ThemeProvider', async () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: MemoryRouter,
|
||||
ErrorBoundaryFallback: () => null,
|
||||
ThemeProvider: ({ children }: PropsWithChildren<{}>) => (
|
||||
<main role="main">{children}</main>
|
||||
),
|
||||
};
|
||||
|
||||
const App = createApp({ components }).getProvider();
|
||||
|
||||
await renderWithEffects(<App />);
|
||||
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 { apis, components, icons, themes } from './defaults';
|
||||
import {
|
||||
AppTheme,
|
||||
BackstagePlugin,
|
||||
IconComponent,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AppComponents,
|
||||
AppOptions,
|
||||
AppIcons,
|
||||
createSpecializedApp,
|
||||
} from '@backstage/core-app-api';
|
||||
|
||||
/**
|
||||
* Creates a new Backstage App using a default set of components, icons and themes unless
|
||||
* they are explicitly provided.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createApp(
|
||||
options?: Omit<AppOptions, keyof OptionalAppOptions> & OptionalAppOptions,
|
||||
) {
|
||||
return createSpecializedApp({
|
||||
...options,
|
||||
apis: options?.apis ?? [],
|
||||
bindRoutes: options?.bindRoutes,
|
||||
components: {
|
||||
...components,
|
||||
...options?.components,
|
||||
},
|
||||
configLoader: options?.configLoader,
|
||||
defaultApis: apis,
|
||||
icons: {
|
||||
...icons,
|
||||
...options?.icons,
|
||||
},
|
||||
plugins: (options?.plugins as BackstagePlugin<any, any>[]) ?? [],
|
||||
themes: options?.themes ?? themes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of app options that {@link createApp} will provide defaults for
|
||||
* if they are not passed in explicitly.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type OptionalAppOptions = {
|
||||
/**
|
||||
* A set of icons to override the default icons with.
|
||||
*
|
||||
* The override is applied for each icon individually.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
icons?: Partial<AppIcons> & {
|
||||
[key in string]: IconComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of themes that override all of the default app themes.
|
||||
*
|
||||
* If this option is provided none of the default themes will be used.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
themes?: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[]; // TODO: simplify once AppTheme is updated
|
||||
|
||||
/**
|
||||
* A set of components to override the default components with.
|
||||
*
|
||||
* The override is applied for each icon individually.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
components?: Partial<AppComponents>;
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* 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 {
|
||||
AlertApiForwarder,
|
||||
NoOpAnalyticsApi,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
GithubAuth,
|
||||
OAuth2,
|
||||
OktaAuth,
|
||||
GitlabAuth,
|
||||
Auth0Auth,
|
||||
MicrosoftAuth,
|
||||
BitbucketAuth,
|
||||
OAuthRequestManager,
|
||||
WebStorage,
|
||||
UrlPatternDiscovery,
|
||||
SamlAuth,
|
||||
OneLoginAuth,
|
||||
UnhandledErrorForwarder,
|
||||
AtlassianAuth,
|
||||
} from '@backstage/core-app-api';
|
||||
|
||||
import {
|
||||
createApiFactory,
|
||||
alertApiRef,
|
||||
analyticsApiRef,
|
||||
errorApiRef,
|
||||
discoveryApiRef,
|
||||
oauthRequestApiRef,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
oauth2ApiRef,
|
||||
oktaAuthApiRef,
|
||||
gitlabAuthApiRef,
|
||||
auth0AuthApiRef,
|
||||
microsoftAuthApiRef,
|
||||
storageApiRef,
|
||||
configApiRef,
|
||||
samlAuthApiRef,
|
||||
oneloginAuthApiRef,
|
||||
oidcAuthApiRef,
|
||||
bitbucketAuthApiRef,
|
||||
atlassianAuthApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const apis = [
|
||||
createApiFactory({
|
||||
api: discoveryApiRef,
|
||||
deps: { configApi: configApiRef },
|
||||
factory: ({ configApi }) =>
|
||||
UrlPatternDiscovery.compile(
|
||||
`${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`,
|
||||
),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: alertApiRef,
|
||||
deps: {},
|
||||
factory: () => new AlertApiForwarder(),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: analyticsApiRef,
|
||||
deps: {},
|
||||
factory: () => new NoOpAnalyticsApi(),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: errorApiRef,
|
||||
deps: { alertApi: alertApiRef },
|
||||
factory: ({ alertApi }) => {
|
||||
const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder());
|
||||
UnhandledErrorForwarder.forward(errorApi, { hidden: false });
|
||||
return errorApi;
|
||||
},
|
||||
}),
|
||||
createApiFactory({
|
||||
api: storageApiRef,
|
||||
deps: { errorApi: errorApiRef },
|
||||
factory: ({ errorApi }) => WebStorage.create({ errorApi }),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oauthRequestApiRef,
|
||||
deps: {},
|
||||
factory: () => new OAuthRequestManager(),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: googleAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
GoogleAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: microsoftAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
MicrosoftAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: githubAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
GithubAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
defaultScopes: ['read:user'],
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oktaAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
OktaAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: gitlabAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
GitlabAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: auth0AuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
Auth0Auth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oauth2ApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: samlAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, configApi }) =>
|
||||
SamlAuth.create({
|
||||
discoveryApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oneloginAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
OneLoginAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oidcAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
provider: {
|
||||
id: 'oidc',
|
||||
title: 'Your Identity Provider',
|
||||
icon: () => null,
|
||||
},
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: bitbucketAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
|
||||
BitbucketAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
defaultScopes: ['team'],
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: atlassianAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi, configApi }) => {
|
||||
return AtlassianAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { OptionallyWrapInRouter } from './components';
|
||||
|
||||
describe('OptionallyWrapInRouter', () => {
|
||||
it('should wrap with router if not yet inside a router', async () => {
|
||||
render(<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>);
|
||||
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not wrap with router if already inside a router', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 React, { ReactNode } from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import { ErrorPanel, Progress, ErrorPage } from '@backstage/core-components';
|
||||
import {
|
||||
MemoryRouter,
|
||||
useInRouterContext,
|
||||
BrowserRouter,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
AppComponents,
|
||||
BootErrorPageProps,
|
||||
ErrorBoundaryFallbackProps,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export function OptionallyWrapInRouter({ children }: { children: ReactNode }) {
|
||||
if (useInRouterContext()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <MemoryRouter>{children}</MemoryRouter>;
|
||||
}
|
||||
|
||||
const DefaultNotFoundPage = () => (
|
||||
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
|
||||
);
|
||||
|
||||
const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => {
|
||||
let message = '';
|
||||
if (step === 'load-config') {
|
||||
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
|
||||
} else if (step === 'load-chunk') {
|
||||
message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`;
|
||||
}
|
||||
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
|
||||
return (
|
||||
<OptionallyWrapInRouter>
|
||||
<ErrorPage status="501" statusMessage={message} />
|
||||
</OptionallyWrapInRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultErrorBoundaryFallback = ({
|
||||
error,
|
||||
resetError,
|
||||
plugin,
|
||||
}: ErrorBoundaryFallbackProps) => {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={`Error in ${plugin?.getId()}`}
|
||||
defaultExpanded
|
||||
error={error}
|
||||
>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const components: AppComponents = {
|
||||
Progress,
|
||||
Router: BrowserRouter,
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
|
||||
};
|
||||
+22
-47
@@ -35,52 +35,27 @@ import MuiPeopleIcon from '@material-ui/icons/People';
|
||||
import MuiPersonIcon from '@material-ui/icons/Person';
|
||||
import MuiWarningIcon from '@material-ui/icons/Warning';
|
||||
|
||||
type AppIconsKey =
|
||||
| 'brokenImage'
|
||||
| 'catalog'
|
||||
| 'scaffolder'
|
||||
| 'techdocs'
|
||||
| 'search'
|
||||
| 'chat'
|
||||
| 'dashboard'
|
||||
| 'docs'
|
||||
| 'email'
|
||||
| 'github'
|
||||
| 'group'
|
||||
| 'help'
|
||||
| 'kind:api'
|
||||
| 'kind:component'
|
||||
| 'kind:domain'
|
||||
| 'kind:group'
|
||||
| 'kind:location'
|
||||
| 'kind:system'
|
||||
| 'kind:user'
|
||||
| 'user'
|
||||
| 'warning';
|
||||
|
||||
export type AppIcons = { [key in AppIconsKey]: IconComponent };
|
||||
|
||||
export const defaultAppIcons: AppIcons = {
|
||||
brokenImage: MuiBrokenImageIcon,
|
||||
export const icons = {
|
||||
brokenImage: MuiBrokenImageIcon as IconComponent,
|
||||
// To be confirmed: see https://github.com/backstage/backstage/issues/4970
|
||||
catalog: MuiMenuBookIcon,
|
||||
scaffolder: MuiCreateNewFolderIcon,
|
||||
techdocs: MuiSubjectIcon,
|
||||
search: MuiSearchIcon,
|
||||
chat: MuiChatIcon,
|
||||
dashboard: MuiDashboardIcon,
|
||||
docs: MuiDocsIcon,
|
||||
email: MuiEmailIcon,
|
||||
github: MuiGitHubIcon,
|
||||
group: MuiPeopleIcon,
|
||||
help: MuiHelpIcon,
|
||||
'kind:api': MuiExtensionIcon,
|
||||
'kind:component': MuiMemoryIcon,
|
||||
'kind:domain': MuiApartmentIcon,
|
||||
'kind:group': MuiPeopleIcon,
|
||||
'kind:location': MuiLocationOnIcon,
|
||||
'kind:system': MuiCategoryIcon,
|
||||
'kind:user': MuiPersonIcon,
|
||||
user: MuiPersonIcon,
|
||||
warning: MuiWarningIcon,
|
||||
catalog: MuiMenuBookIcon as IconComponent,
|
||||
scaffolder: MuiCreateNewFolderIcon as IconComponent,
|
||||
techdocs: MuiSubjectIcon as IconComponent,
|
||||
search: MuiSearchIcon as IconComponent,
|
||||
chat: MuiChatIcon as IconComponent,
|
||||
dashboard: MuiDashboardIcon as IconComponent,
|
||||
docs: MuiDocsIcon as IconComponent,
|
||||
email: MuiEmailIcon as IconComponent,
|
||||
github: MuiGitHubIcon as IconComponent,
|
||||
group: MuiPeopleIcon as IconComponent,
|
||||
help: MuiHelpIcon as IconComponent,
|
||||
'kind:api': MuiExtensionIcon as IconComponent,
|
||||
'kind:component': MuiMemoryIcon as IconComponent,
|
||||
'kind:domain': MuiApartmentIcon as IconComponent,
|
||||
'kind:group': MuiPeopleIcon as IconComponent,
|
||||
'kind:location': MuiLocationOnIcon as IconComponent,
|
||||
'kind:system': MuiCategoryIcon as IconComponent,
|
||||
'kind:user': MuiPersonIcon as IconComponent,
|
||||
user: MuiPersonIcon as IconComponent,
|
||||
warning: MuiWarningIcon as IconComponent,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { apis } from './apis';
|
||||
export { components } from './components';
|
||||
export { icons } from './icons';
|
||||
export { themes } from './themes';
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { darkTheme, lightTheme } from '@backstage/theme';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import { AppTheme } from '@backstage/core-plugin-api';
|
||||
|
||||
export const themes: AppTheme[] = [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
icon: <LightIcon />,
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
title: 'Dark Theme',
|
||||
variant: 'dark',
|
||||
icon: <DarkIcon />,
|
||||
theme: darkTheme,
|
||||
Provider: ({ children }) => (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides the default wiring of a Backstage App
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { createApp } from './createApp';
|
||||
export type { OptionalAppOptions } from './createApp';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"bundled": true,
|
||||
"dependencies": {
|
||||
"@backstage/app-defaults": "^0.1.0",
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/cli": "^0.8.0",
|
||||
"@backstage/core-app-api": "^0.1.18",
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
RELATION_PART_OF,
|
||||
RELATION_PROVIDES_API,
|
||||
} from '@backstage/catalog-model';
|
||||
import { createApp, FlatRoutes } from '@backstage/core-app-api';
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
import { FlatRoutes } from '@backstage/core-app-api';
|
||||
import {
|
||||
AlertDisplay,
|
||||
OAuthRequestDialog,
|
||||
@@ -93,7 +94,6 @@ const app = createApp({
|
||||
// Custom icon example
|
||||
alert: AlarmIcon,
|
||||
},
|
||||
|
||||
components: {
|
||||
SignInPage: props => {
|
||||
return (
|
||||
|
||||
@@ -374,6 +374,9 @@ export class GitlabUrlReader implements UrlReader {
|
||||
|
||||
export { isChildPath };
|
||||
|
||||
// @public
|
||||
export function isDatabaseConflictError(e: unknown): boolean;
|
||||
|
||||
// @public
|
||||
export function loadBackendConfig(options: {
|
||||
logger: Logger_2;
|
||||
|
||||
@@ -13,14 +13,15 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import { omit } from 'lodash';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
createDatabaseClient,
|
||||
ensureDatabaseExists,
|
||||
createNameOverride,
|
||||
ensureDatabaseExists,
|
||||
normalizeConnection,
|
||||
} from './connection';
|
||||
import { PluginDatabaseManager } from './types';
|
||||
@@ -165,7 +166,7 @@ export class DatabaseManager {
|
||||
);
|
||||
|
||||
return {
|
||||
// include base connection if client type has not been overriden
|
||||
// include base connection if client type has not been overridden
|
||||
...(overridden ? {} : baseConnection),
|
||||
...connection,
|
||||
};
|
||||
|
||||
@@ -28,3 +28,4 @@ export {
|
||||
} from './connection';
|
||||
|
||||
export type { PluginDatabaseManager } from './types';
|
||||
export { isDatabaseConflictError } from './util';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tries to deduce whether a thrown error is a database conflict.
|
||||
*
|
||||
* @public
|
||||
* @param e - A thrown error
|
||||
* @returns True if the error looks like it was a conflict error thrown by a
|
||||
* known database engine
|
||||
*/
|
||||
export function isDatabaseConflictError(e: unknown) {
|
||||
const message = (e as any)?.message;
|
||||
|
||||
return (
|
||||
typeof message === 'string' &&
|
||||
(/SQLITE_CONSTRAINT: UNIQUE/.test(message) ||
|
||||
/unique constraint/.test(message))
|
||||
);
|
||||
}
|
||||
@@ -174,27 +174,27 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
const server: http.Server = httpsSettings
|
||||
? await createHttpsServer(app, httpsSettings, logger)
|
||||
: createHttpServer(app, logger);
|
||||
const stoppableServer = stoppable(server, 0);
|
||||
|
||||
useHotCleanup(this.module, () =>
|
||||
stoppableServer.stop((e: any) => {
|
||||
if (e) console.error(e);
|
||||
}),
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
app.on('error', e => {
|
||||
logger.error(`Failed to start up on port ${port}, ${e}`);
|
||||
function handleStartupError(e: unknown) {
|
||||
server.close();
|
||||
reject(e);
|
||||
}
|
||||
|
||||
server.on('error', handleStartupError);
|
||||
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', handleStartupError);
|
||||
logger.info(`Listening on ${host}:${port}`);
|
||||
resolve(stoppableServer);
|
||||
});
|
||||
|
||||
const stoppableServer = stoppable(
|
||||
server.listen(port, host, () => {
|
||||
logger.info(`Listening on ${host}:${port}`);
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
useHotCleanup(this.module, () =>
|
||||
stoppableServer.stop((e: any) => {
|
||||
if (e) console.error(e);
|
||||
}),
|
||||
);
|
||||
|
||||
resolve(stoppableServer);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
# @backstage/backend-tasks
|
||||
|
||||
Common distributed task management for Backstage backends.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the library to your backend package:
|
||||
|
||||
```sh
|
||||
# From your Backstage root directory
|
||||
cd packages/backend
|
||||
yarn add @backstage/backend-tasks
|
||||
```
|
||||
|
||||
then make use of its facilities as necessary:
|
||||
|
||||
```typescript
|
||||
import { TaskScheduler } from '@backstage/backend-tasks';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin');
|
||||
|
||||
await scheduler.scheduleTask({
|
||||
id: 'refresh-things',
|
||||
frequency: Duration.fromObject({ minutes: 10 }),
|
||||
fn: async () => {
|
||||
await entityProvider.run();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
## 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 { AbortSignal as AbortSignal_2 } from 'node-abort-controller';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DatabaseManager } from '@backstage/backend-common';
|
||||
import { Duration } from 'luxon';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
|
||||
// @public
|
||||
export interface PluginTaskScheduler {
|
||||
scheduleTask(task: TaskDefinition): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface TaskDefinition {
|
||||
fn: TaskFunction;
|
||||
frequency: Duration;
|
||||
id: string;
|
||||
initialDelay?: Duration;
|
||||
signal?: AbortSignal_2;
|
||||
timeout: Duration;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TaskFunction =
|
||||
| ((abortSignal: AbortSignal_2) => void | Promise<void>)
|
||||
| (() => void | Promise<void>);
|
||||
|
||||
// @public
|
||||
export class TaskScheduler {
|
||||
constructor(databaseManager: DatabaseManager, logger: Logger_2);
|
||||
forPlugin(pluginId: string): PluginTaskScheduler;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
databaseManager?: DatabaseManager;
|
||||
logger?: Logger_2;
|
||||
},
|
||||
): TaskScheduler;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
//
|
||||
// tasks
|
||||
//
|
||||
await knex.schema.createTable('backstage_backend_tasks__tasks', table => {
|
||||
table.comment('Tasks used for scheduling work on multiple workers');
|
||||
table
|
||||
.text('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');
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@backstage/backend-tasks",
|
||||
"description": "Common distributed task management library for Backstage backends",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-tasks"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "backstage-cli build --outputs cjs,types",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.8",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.4",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@types/luxon": "^2.0.4",
|
||||
"knex": "^0.95.1",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"node-abort-controller": "^3.0.1",
|
||||
"uuid": "^8.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"zod": "^3.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "^0.1.8",
|
||||
"@backstage/cli": "^0.8.1",
|
||||
"jest": "^26.0.1",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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-common';
|
||||
import { Knex } from 'knex';
|
||||
import { DB_MIGRATIONS_TABLE } from './tables';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/backend-tasks',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
export async function migrateBackendTasks(knex: Knex): Promise<void> {
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
tableName: DB_MIGRATIONS_TABLE,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const DB_MIGRATIONS_TABLE = 'backstage_backend_tasks__knex_migrations';
|
||||
export const DB_TASKS_TABLE = 'backstage_backend_tasks__tasks';
|
||||
|
||||
export type DbTasksRow = {
|
||||
id: string;
|
||||
settings_json: string;
|
||||
next_run_start_at: Date;
|
||||
current_run_ticket?: string;
|
||||
current_run_started_at?: Date | string;
|
||||
current_run_expires_at?: Date | string;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './tasks';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Duration } from 'luxon';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
|
||||
|
||||
describe('PluginTaskManagerImpl', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
async function init(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateBackendTasks(knex);
|
||||
const manager = new PluginTaskSchedulerImpl(
|
||||
async () => knex,
|
||||
getVoidLogger(),
|
||||
);
|
||||
return { knex, manager };
|
||||
}
|
||||
|
||||
// This is just to test the wrapper code; most of the actual tests are in
|
||||
// TaskWorker.test.ts
|
||||
describe('scheduleTask', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can run the happy path, %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,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { Logger } from 'winston';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
import { PluginTaskScheduler, TaskDefinition } from './types';
|
||||
import { validateId } from './util';
|
||||
|
||||
/**
|
||||
* Implements the actual task management.
|
||||
*/
|
||||
export class PluginTaskSchedulerImpl implements PluginTaskScheduler {
|
||||
constructor(
|
||||
private readonly databaseFactory: () => Promise<Knex>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async scheduleTask(task: TaskDefinition): Promise<void> {
|
||||
validateId(task.id);
|
||||
|
||||
const knex = await this.databaseFactory();
|
||||
|
||||
const worker = new TaskWorker(task.id, task.fn, knex, this.logger);
|
||||
await worker.start(
|
||||
{
|
||||
version: 1,
|
||||
initialDelayDuration: task.initialDelay?.toISO(),
|
||||
recurringAtMostEveryDuration: task.frequency.toISO(),
|
||||
timeoutAfterDuration: task.timeout.toISO(),
|
||||
},
|
||||
{
|
||||
signal: task.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { AbortSignal } from 'node-abort-controller';
|
||||
import { Logger } from 'winston';
|
||||
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
|
||||
import { sleep } from './util';
|
||||
|
||||
/**
|
||||
* Makes sure to auto-expire and clean up things that time out or for other
|
||||
* reasons should not be left lingering.
|
||||
*/
|
||||
export class PluginTaskSchedulerJanitor {
|
||||
private readonly knex: Knex;
|
||||
private readonly waitBetweenRuns: Duration;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: {
|
||||
knex: Knex;
|
||||
waitBetweenRuns: Duration;
|
||||
logger: Logger;
|
||||
}) {
|
||||
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() {
|
||||
// SQLite currently (Oct 1 2021) returns a number for returning()
|
||||
// statements, effectively ignoring them and instead returning the outcome
|
||||
// of the delete() - and knex also emits a warning about that fact, which
|
||||
// is why we avoid that entirely for the sqlite3 driver.
|
||||
// https://github.com/knex/knex/issues/4370
|
||||
// https://github.com/mapbox/node-sqlite3/issues/1453
|
||||
|
||||
const dbNull = this.knex.raw('null');
|
||||
|
||||
const tasksQuery = 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,
|
||||
});
|
||||
|
||||
if (this.knex.client.config.client === 'sqlite3') {
|
||||
const tasks = await tasksQuery;
|
||||
this.logger.warn(`${tasks} tasks timed out and were lost`);
|
||||
} else {
|
||||
const tasks = await tasksQuery.returning(['id']);
|
||||
for (const { id } of tasks) {
|
||||
this.logger.warn(`Task timed out and was lost: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DatabaseManager, getVoidLogger } from '@backstage/backend-common';
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Duration } from 'luxon';
|
||||
import { TaskScheduler } from './TaskScheduler';
|
||||
import waitForExpect from 'wait-for-expect';
|
||||
|
||||
describe('TaskScheduler', () => {
|
||||
const logger = getVoidLogger();
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
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 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),
|
||||
fn,
|
||||
});
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 } from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { once } from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { Logger } from 'winston';
|
||||
import { migrateBackendTasks } from '../database/migrateBackendTasks';
|
||||
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
|
||||
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
|
||||
import { PluginTaskScheduler } from './types';
|
||||
|
||||
/**
|
||||
* Deals with the scheduling of distributed tasks.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class TaskScheduler {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
databaseManager?: DatabaseManager;
|
||||
logger?: Logger;
|
||||
},
|
||||
): 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: DatabaseManager,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
forPlugin(pluginId: string): PluginTaskScheduler {
|
||||
const databaseFactory = once(async () => {
|
||||
const knex = await this.databaseManager.forPlugin(pluginId).getClient();
|
||||
|
||||
await migrateBackendTasks(knex);
|
||||
|
||||
const janitor = new PluginTaskSchedulerJanitor({
|
||||
knex,
|
||||
waitBetweenRuns: Duration.fromObject({ minutes: 1 }),
|
||||
logger: this.logger,
|
||||
});
|
||||
janitor.start();
|
||||
|
||||
return knex;
|
||||
});
|
||||
|
||||
return new PluginTaskSchedulerImpl(
|
||||
databaseFactory,
|
||||
this.logger.child({ plugin: pluginId }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Duration } 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 { TaskSettingsV1 } from './types';
|
||||
|
||||
describe('TaskWorker', () => {
|
||||
const logger = getVoidLogger();
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
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: TaskSettingsV1 = {
|
||||
version: 1,
|
||||
initialDelayDuration: Duration.fromMillis(1000).toISO(),
|
||||
recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(),
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).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: 1,
|
||||
initialDelayDuration: 'PT1S',
|
||||
recurringAtMostEveryDuration: 'PT2S',
|
||||
timeoutAfterDuration: 'PT60S',
|
||||
});
|
||||
|
||||
await expect(worker.findReadyTask()).resolves.toEqual({
|
||||
result: 'not-ready-yet',
|
||||
});
|
||||
|
||||
waitForExpect(async () => {
|
||||
await expect(worker.findReadyTask()).resolves.toEqual({
|
||||
result: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
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: TaskSettingsV1 = {
|
||||
version: 1,
|
||||
initialDelayDuration: undefined,
|
||||
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task1', fn, knex, logger);
|
||||
worker.start(settings);
|
||||
|
||||
waitForExpect(() => {
|
||||
expect(fn).toBeCalledTimes(3);
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
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: TaskSettingsV1 = {
|
||||
version: 1,
|
||||
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
|
||||
timeoutAfterDuration: Duration.fromMillis(60000).toISO(),
|
||||
};
|
||||
|
||||
const worker = new TaskWorker('task1', fn, knex, logger);
|
||||
await worker.persistTask(settings);
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
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: TaskSettingsV1 = {
|
||||
version: 1,
|
||||
recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(),
|
||||
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 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 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,
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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 { AbortSignal } from 'node-abort-controller';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables';
|
||||
import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types';
|
||||
import { delegateAbortController, nowPlus, sleep } from './util';
|
||||
|
||||
const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 });
|
||||
|
||||
/**
|
||||
* Performs the actual work of a task.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export class TaskWorker {
|
||||
private readonly taskId: string;
|
||||
private readonly fn: TaskFunction;
|
||||
private readonly knex: Knex;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(taskId: string, fn: TaskFunction, knex: Knex, logger: Logger) {
|
||||
this.taskId = taskId;
|
||||
this.fn = fn;
|
||||
this.knex = knex;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async start(settings: TaskSettingsV1, 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)}`,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (!options?.signal?.aborted) {
|
||||
const runResult = await this.runOnce(options?.signal);
|
||||
if (runResult.result === 'abort') {
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(WORK_CHECK_FREQUENCY, options?.signal);
|
||||
}
|
||||
this.logger.info(`Task worker finished: ${this.taskId}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Task worker failed unexpectedly, ${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a single attempt at running the task to completion, if ready.
|
||||
*
|
||||
* @returns The outcome of the attempt
|
||||
*/
|
||||
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);
|
||||
} catch (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: TaskSettingsV1) {
|
||||
// Perform an initial parse to ensure that we will definitely be able to
|
||||
// read it back again.
|
||||
taskSettingsV1Schema.parse(settings);
|
||||
|
||||
const settingsJson = JSON.stringify(settings);
|
||||
const startAt = settings.initialDelayDuration
|
||||
? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex)
|
||||
: this.knex.fn.now();
|
||||
|
||||
// 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.
|
||||
await this.knex<DbTasksRow>(DB_TASKS_TABLE)
|
||||
.insert({
|
||||
id: this.taskId,
|
||||
settings_json: settingsJson,
|
||||
next_run_start_at: startAt,
|
||||
})
|
||||
.onConflict('id')
|
||||
.merge(['settings_json']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the task is ready to run
|
||||
*/
|
||||
async findReadyTask(): Promise<
|
||||
| { result: 'not-ready-yet' }
|
||||
| { result: 'abort' }
|
||||
| { result: 'ready'; settings: TaskSettingsV1 }
|
||||
> {
|
||||
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 settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson));
|
||||
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: TaskSettingsV1,
|
||||
): 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: TaskSettingsV1,
|
||||
): Promise<boolean> {
|
||||
const { recurringAtMostEveryDuration } = settings;
|
||||
|
||||
// We make an effort to keep the datetime calculations in the database
|
||||
// layer, making sure to not have to perform conversions back and forth and
|
||||
// leaning on the database as a central clock source
|
||||
const dbNull = this.knex.raw('null');
|
||||
const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds');
|
||||
const nextRun =
|
||||
this.knex.client.config.client === 'sqlite3'
|
||||
? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`])
|
||||
: this.knex.raw(`next_run_start_at + interval '${dt} seconds'`);
|
||||
|
||||
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: dbNull,
|
||||
current_run_started_at: dbNull,
|
||||
current_run_expires_at: dbNull,
|
||||
});
|
||||
|
||||
return rows === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { TaskScheduler } from './TaskScheduler';
|
||||
export type {
|
||||
PluginTaskScheduler,
|
||||
TaskDefinition,
|
||||
TaskFunction,
|
||||
} from './types';
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 { Duration } from 'luxon';
|
||||
import { AbortSignal } from 'node-abort-controller';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A function that can be called as a scheduled task.
|
||||
*
|
||||
* It may optionally accept an abort signal argument. When the signal triggers,
|
||||
* processing should abort and return as quickly as possible.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TaskFunction =
|
||||
| ((abortSignal: AbortSignal) => void | Promise<void>)
|
||||
| (() => void | Promise<void>);
|
||||
|
||||
/**
|
||||
* Options that apply to the invocation of a given task.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TaskDefinition {
|
||||
/**
|
||||
* A unique ID (within the scope of the plugin) for the task.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The actual task function to be invoked regularly.
|
||||
*/
|
||||
fn: TaskFunction;
|
||||
|
||||
/**
|
||||
* An abort signal that, when triggered, will stop the recurring execution of
|
||||
* the task.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
|
||||
/**
|
||||
* The maximum amount of time that a single task invocation can take, before
|
||||
* it's considered timed out and gets "released" such that a new invocation
|
||||
* is permitted to take place (possibly, then, on a different worker).
|
||||
*
|
||||
* If no value is given for this field then there is no timeout. This is
|
||||
* potentially dangerous.
|
||||
*/
|
||||
timeout: Duration;
|
||||
|
||||
/**
|
||||
* The amount of time that should pass between task invocation starts.
|
||||
* Essentially, this equals roughly how often you want the task to run.
|
||||
*
|
||||
* This is a best effort value; under some circumstances there can be
|
||||
* deviations. For example, if the task runtime is longer than the frequency
|
||||
* and the timeout has not been given or not been exceeded yet, the next
|
||||
* invocation of this task will be delayed until after the previous one
|
||||
* finishes.
|
||||
*
|
||||
* The system does its best to avoid overlapping invocations.
|
||||
*
|
||||
* If no value is given for this field then the task will only be invoked
|
||||
* once (on any worker) and then unscheduled automatically.
|
||||
*/
|
||||
frequency: Duration;
|
||||
|
||||
/**
|
||||
* The amount of time that should pass before the first invocation happens.
|
||||
*
|
||||
* This can be useful in cold start scenarios to stagger or delay some heavy
|
||||
* compute jobs.
|
||||
*
|
||||
* If no value is given for this field then the first invocation will happen
|
||||
* as soon as possible.
|
||||
*/
|
||||
initialDelay?: Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deals with the scheduling of distributed tasks, for a given plugin.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface PluginTaskScheduler {
|
||||
/**
|
||||
* Schedules a task function for coordinated exclusive invocation across
|
||||
* workers.
|
||||
*
|
||||
* If the task was already scheduled since before by us or by another party,
|
||||
* its options are just overwritten with the given options, and things
|
||||
* continue from there.
|
||||
*
|
||||
* @param definition - The task definition
|
||||
*/
|
||||
scheduleTask(task: TaskDefinition): Promise<void>;
|
||||
}
|
||||
|
||||
function isValidOptionalDurationString(d: string | undefined): boolean {
|
||||
try {
|
||||
return !d || Duration.fromISO(d).isValid === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const taskSettingsV1Schema = z.object({
|
||||
version: z.literal(1),
|
||||
initialDelayDuration: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
|
||||
recurringAtMostEveryDuration: z
|
||||
.string()
|
||||
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
|
||||
timeoutAfterDuration: z
|
||||
.string()
|
||||
.refine(isValidOptionalDurationString, { message: 'Invalid duration' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* The properties that control a scheduled task (version 1).
|
||||
*/
|
||||
export type TaskSettingsV1 = z.infer<typeof taskSettingsV1Schema>;
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 { Duration } from 'luxon';
|
||||
import { AbortController } from 'node-abort-controller';
|
||||
import { delegateAbortController, sleep, validateId } from './util';
|
||||
|
||||
describe('util', () => {
|
||||
describe('validateId', () => {
|
||||
it.each(['a', 'a_b', 'ab123c_2'])(
|
||||
'accepts valid inputs, %p',
|
||||
async input => {
|
||||
expect(validateId(input)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', 'a!', 'A', 'a-b', 'a.b', '_a', 'a_', null, Symbol('a')])(
|
||||
'rejects invalid inputs, %p',
|
||||
async input => {
|
||||
expect(() => validateId(input as any)).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('sleep', () => {
|
||||
it('finishes the wait as expected with no signal', async () => {
|
||||
const ac = new AbortController();
|
||||
const start = Date.now();
|
||||
await sleep(Duration.fromObject({ seconds: 1 }), ac.signal);
|
||||
expect(Date.now() - start).toBeGreaterThan(800);
|
||||
}, 5_000);
|
||||
|
||||
it('aborts properly on the signal', async () => {
|
||||
const ac = new AbortController();
|
||||
const promise = sleep(Duration.fromObject({ seconds: 10 }), ac.signal);
|
||||
ac.abort();
|
||||
await promise;
|
||||
expect(true).toBe(true);
|
||||
}, 1_000);
|
||||
});
|
||||
|
||||
describe('delegateAbortController', () => {
|
||||
it('inherits parent abort state', () => {
|
||||
const parent = new AbortController();
|
||||
const child = delegateAbortController(parent.signal);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
parent.abort();
|
||||
expect(parent.signal.aborted).toBe(true);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not inherit from child to parent', () => {
|
||||
const parent = new AbortController();
|
||||
const child = delegateAbortController(parent.signal);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
child.abort();
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { AbortController, AbortSignal } from 'node-abort-controller';
|
||||
|
||||
// Keep the IDs compatible with e.g. Prometheus
|
||||
export function validateId(id: string) {
|
||||
if (typeof id !== 'string' || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(id)) {
|
||||
throw new InputError(
|
||||
`${id} is not a valid ID, expected string of lowercase characters and digits separated by underscores`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function dbTime(t: Date | string): DateTime {
|
||||
if (typeof t === 'string') {
|
||||
return DateTime.fromSQL(t);
|
||||
}
|
||||
return DateTime.fromJSDate(t);
|
||||
}
|
||||
|
||||
export function nowPlus(duration: Duration | undefined, knex: Knex) {
|
||||
const seconds = duration?.as('seconds') ?? 0;
|
||||
if (!seconds) {
|
||||
return knex.fn.now();
|
||||
}
|
||||
return knex.client.config.client === 'sqlite3'
|
||||
? knex.raw(`datetime('now', ?)`, [`${seconds} seconds`])
|
||||
: knex.raw(`now() + interval '${seconds} seconds'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for the given duration, but return sooner if the abort signal
|
||||
* triggers.
|
||||
*
|
||||
* @param duration - The amount of time to sleep, at most
|
||||
* @param abortSignal - An optional abort signal that short circuits the wait
|
||||
*/
|
||||
export async function sleep(
|
||||
duration: Duration,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const done = () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
abortSignal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
|
||||
timeoutHandle = setTimeout(done, duration.as('milliseconds'));
|
||||
abortSignal?.addEventListener('abort', done);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AbortController that, in addition to working as a regular
|
||||
* standalone controller, also gets aborted if the given parent signal
|
||||
* reaches aborted state.
|
||||
*
|
||||
* @param parent - The "parent" signal that can trigger the delegate
|
||||
*/
|
||||
export function delegateAbortController(parent?: AbortSignal): AbortController {
|
||||
const delegate = new AbortController();
|
||||
|
||||
if (parent) {
|
||||
if (parent.aborted) {
|
||||
delegate.abort();
|
||||
} else {
|
||||
const onParentAborted = () => {
|
||||
delegate.abort();
|
||||
};
|
||||
|
||||
const onChildAborted = () => {
|
||||
parent.removeEventListener('abort', onParentAborted);
|
||||
};
|
||||
|
||||
parent.addEventListener('abort', onParentAborted, { once: true });
|
||||
delegate.signal.addEventListener('abort', onChildAborted, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return delegate;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
|
||||
version: v1.22.1
|
||||
# ignores vulnerabilities until expiry date; change duration by modifying expiry date
|
||||
ignore:
|
||||
SNYK-JS-TAR-1579155:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1579152:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1579147:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536758:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536531:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536528:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
patch: {}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
|
||||
version: v1.22.1
|
||||
# ignores vulnerabilities until expiry date; change duration by modifying expiry date
|
||||
ignore:
|
||||
SNYK-JS-TAR-1579155:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1579152:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1579147:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536758:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536531:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
SNYK-JS-TAR-1536528:
|
||||
- 'sqlite3 > node-gyp > tar':
|
||||
reason: >-
|
||||
The only usage is via node-gyp; there is no unpacking of untrusted tar
|
||||
files
|
||||
expires: 2022-11-11T14:30:05.581Z
|
||||
created: 2021-11-11T14:30:05.582Z
|
||||
patch: {}
|
||||
@@ -48,12 +48,12 @@ import scaffolder from './plugins/scaffolder';
|
||||
import proxy from './plugins/proxy';
|
||||
import search from './plugins/search';
|
||||
import techdocs from './plugins/techdocs';
|
||||
import techInsights from './plugins/techInsights';
|
||||
import todo from './plugins/todo';
|
||||
import graphql from './plugins/graphql';
|
||||
import app from './plugins/app';
|
||||
import badges from './plugins/badges';
|
||||
import jenkins from './plugins/jenkins';
|
||||
import techInsights from './plugins/techInsights';
|
||||
import { PluginEnvironment } from './types';
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/errors": "^0.1.4",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/json-schema": "^7.0.5",
|
||||
"@types/yup": "^0.29.13",
|
||||
"ajv": "^7.0.3",
|
||||
"json-schema": "^0.3.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"uuid": "^8.0.0",
|
||||
"yup": "^0.32.9"
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@lerna/package-graph": "^4.0.0",
|
||||
"@lerna/project": "^4.0.0",
|
||||
"@octokit/request": "^5.4.12",
|
||||
"@rollup/plugin-commonjs": "^17.1.0",
|
||||
"@rollup/plugin-commonjs": "^21.0.1",
|
||||
"@rollup/plugin-json": "^4.0.2",
|
||||
"@rollup/plugin-node-resolve": "^13.0.0",
|
||||
"@rollup/plugin-yaml": "^3.0.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"inquirer": "^7.0.4",
|
||||
"jest": "^26.0.1",
|
||||
"jest-css-modules": "^2.1.0",
|
||||
"json-schema": "^0.3.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"jest-transform-yaml": "^0.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mini-css-extract-plugin": "^2.4.2",
|
||||
|
||||
@@ -32,6 +32,23 @@ export const transforms = (options: TransformOptions): Transforms => {
|
||||
|
||||
const extraTransforms = isDev ? ['react-hot-loader'] : [];
|
||||
|
||||
// This ensures that styles inserted from the style-loader and any
|
||||
// async style chunks are always given lower priority than JSS styles.
|
||||
// Note that this function is stringified and executed in the browser
|
||||
// after transpilation, so stick to simple syntax
|
||||
function insertBeforeJssStyles(element: any) {
|
||||
const head = document.head;
|
||||
// This makes sure that any style elements we insert get put before the
|
||||
// dynamic styles from JSS, such as the ones from `makeStyles()`.
|
||||
// TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it.
|
||||
const firstJssNode = head.querySelector('style[data-jss]');
|
||||
if (!firstJssNode) {
|
||||
head.appendChild(element);
|
||||
} else {
|
||||
head.insertBefore(element, firstJssNode);
|
||||
}
|
||||
}
|
||||
|
||||
const loaders = [
|
||||
{
|
||||
test: /\.(tsx?)$/,
|
||||
@@ -112,7 +129,14 @@ export const transforms = (options: TransformOptions): Transforms => {
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
|
||||
isDev
|
||||
? {
|
||||
loader: require.resolve('style-loader'),
|
||||
options: {
|
||||
insert: insertBeforeJssStyles,
|
||||
},
|
||||
}
|
||||
: MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
@@ -132,6 +156,7 @@ export const transforms = (options: TransformOptions): Transforms => {
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'static/[name].[contenthash:8].css',
|
||||
chunkFilename: 'static/[name].[id].[contenthash:8].css',
|
||||
insert: insertBeforeJssStyles, // Only applies to async chunks
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,18 +106,19 @@ export async function createDistWorkspace(
|
||||
|
||||
if (options.buildDependencies) {
|
||||
const exclude = options.buildExcludes ?? [];
|
||||
const scopeArgs = targets
|
||||
.filter(target => !exclude.includes(target.name))
|
||||
.flatMap(target => ['--scope', target.name]);
|
||||
|
||||
const lernaArgs =
|
||||
options.parallel && Number.isInteger(options.parallel)
|
||||
? ['--concurrency', options.parallel.toString()]
|
||||
: [];
|
||||
const toBuild = targets.filter(target => !exclude.includes(target.name));
|
||||
if (toBuild.length > 0) {
|
||||
const scopeArgs = toBuild.flatMap(target => ['--scope', target.name]);
|
||||
const lernaArgs =
|
||||
options.parallel && Number.isInteger(options.parallel)
|
||||
? ['--concurrency', options.parallel.toString()]
|
||||
: [];
|
||||
|
||||
await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], {
|
||||
cwd: paths.targetRoot,
|
||||
});
|
||||
await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], {
|
||||
cwd: paths.targetRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await moveToDistWorkspace(targetDir, targets);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"ajv": "^7.0.3",
|
||||
"chokidar": "^3.5.2",
|
||||
"fs-extra": "9.1.0",
|
||||
"json-schema": "^0.3.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"json-schema-merge-allof": "^0.8.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"typescript-json-schema": "^0.51.0",
|
||||
|
||||
@@ -45,6 +45,7 @@ import { Observable } from '@backstage/types';
|
||||
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { oneloginAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
|
||||
import { OptionalAppOptions } from '@backstage/app-defaults';
|
||||
import { PendingAuthRequest } from '@backstage/core-plugin-api';
|
||||
import { PluginOutput } from '@backstage/core-plugin-api';
|
||||
import { ProfileInfo } from '@backstage/core-plugin-api';
|
||||
@@ -150,7 +151,7 @@ export type AppComponents = {
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{}>;
|
||||
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
|
||||
ThemeProvider: ComponentType<{}>;
|
||||
ThemeProvider?: ComponentType<{}>;
|
||||
SignInPage?: ComponentType<SignInPageProps>;
|
||||
};
|
||||
|
||||
@@ -164,15 +165,41 @@ export type AppContext = {
|
||||
getComponents(): AppComponents;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AppIcons = {
|
||||
'kind:api': IconComponent;
|
||||
'kind:component': IconComponent;
|
||||
'kind:domain': IconComponent;
|
||||
'kind:group': IconComponent;
|
||||
'kind:location': IconComponent;
|
||||
'kind:system': IconComponent;
|
||||
'kind:user': IconComponent;
|
||||
brokenImage: IconComponent;
|
||||
catalog: IconComponent;
|
||||
chat: IconComponent;
|
||||
dashboard: IconComponent;
|
||||
docs: IconComponent;
|
||||
email: IconComponent;
|
||||
github: IconComponent;
|
||||
group: IconComponent;
|
||||
help: IconComponent;
|
||||
scaffolder: IconComponent;
|
||||
search: IconComponent;
|
||||
techdocs: IconComponent;
|
||||
user: IconComponent;
|
||||
warning: IconComponent;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AppOptions = {
|
||||
apis?: Iterable<AnyApiFactory>;
|
||||
icons?: Partial<AppIcons> & {
|
||||
defaultApis?: Iterable<AnyApiFactory>;
|
||||
icons: AppIcons & {
|
||||
[key in string]: IconComponent;
|
||||
};
|
||||
plugins?: BackstagePluginWithAnyOutput[];
|
||||
components?: Partial<AppComponents>;
|
||||
themes?: AppTheme[];
|
||||
components: AppComponents;
|
||||
themes: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[];
|
||||
configLoader?: AppConfigLoader;
|
||||
bindRoutes?(context: { bind: AppRouteBinder }): void;
|
||||
};
|
||||
@@ -286,10 +313,11 @@ export type BootErrorPageProps = {
|
||||
|
||||
export { ConfigReader };
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "PrivateAppImpl" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated
|
||||
export function createApp(options?: OptionalAppOptions): BackstageApp;
|
||||
|
||||
// @public
|
||||
export function createApp(options?: AppOptions): PrivateAppImpl;
|
||||
export function createSpecializedApp(options: AppOptions): BackstageApp;
|
||||
|
||||
// @public
|
||||
export const defaultConfigLoader: AppConfigLoader;
|
||||
@@ -608,5 +636,4 @@ export class WebStorage implements StorageApi {
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/apis/system/ApiProvider.d.ts:15:5 - (ae-forgotten-export) The symbol "ApiProviderProps" needs to be exported by the entry point index.d.ts
|
||||
// src/app/types.d.ts:152:5 - (ae-forgotten-export) The symbol "AppIcons" needs to be exported by the entry point index.d.ts
|
||||
```
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/app-defaults": "^0.1.0",
|
||||
"@backstage/core-components": "^0.7.3",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/core-plugin-api": "^0.1.13",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import MockOAuthApi from './MockOAuthApi';
|
||||
import PowerIcon from '@material-ui/icons/Power';
|
||||
|
||||
describe('MockOAuthApi', () => {
|
||||
it('should trigger all requests', async () => {
|
||||
@@ -24,13 +23,13 @@ describe('MockOAuthApi', () => {
|
||||
|
||||
const authHandler1 = jest.fn().mockImplementation(() => authResult);
|
||||
const requester1 = mock.createAuthRequester({
|
||||
provider: { icon: PowerIcon, title: 'Test' },
|
||||
provider: { icon: () => null, title: 'Test' },
|
||||
onAuthRequest: authHandler1,
|
||||
});
|
||||
|
||||
const authHandler2 = jest.fn().mockResolvedValue('other');
|
||||
const requester2 = mock.createAuthRequester({
|
||||
provider: { icon: PowerIcon, title: 'Test' },
|
||||
provider: { icon: () => null, title: 'Test' },
|
||||
onAuthRequest: authHandler2,
|
||||
});
|
||||
|
||||
@@ -67,13 +66,13 @@ describe('MockOAuthApi', () => {
|
||||
|
||||
const authHandler1 = jest.fn();
|
||||
const requester1 = mock.createAuthRequester({
|
||||
provider: { icon: PowerIcon, title: 'Test' },
|
||||
provider: { icon: () => null, title: 'Test' },
|
||||
onAuthRequest: authHandler1,
|
||||
});
|
||||
|
||||
const authHandler2 = jest.fn();
|
||||
const requester2 = mock.createAuthRequester({
|
||||
provider: { icon: PowerIcon, title: 'Test' },
|
||||
provider: { icon: () => null, title: 'Test' },
|
||||
onAuthRequest: authHandler2,
|
||||
});
|
||||
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ProviderIcon from '@material-ui/icons/AcUnit';
|
||||
import { OAuthRequestManager } from './OAuthRequestManager';
|
||||
|
||||
describe('OAuthRequestManager', () => {
|
||||
@@ -27,7 +26,7 @@ describe('OAuthRequestManager', () => {
|
||||
const requester = manager.createAuthRequester({
|
||||
provider: {
|
||||
title: 'My Provider',
|
||||
icon: ProviderIcon,
|
||||
icon: () => null,
|
||||
},
|
||||
onAuthRequest: async () => 'hello',
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import AtlassianIcon from '@material-ui/icons/AcUnit';
|
||||
import { atlassianAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'atlassian',
|
||||
title: 'Atlassian',
|
||||
icon: AtlassianIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Auth0Icon from '@material-ui/icons/AcUnit';
|
||||
import { auth0AuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'auth0',
|
||||
title: 'Auth0',
|
||||
icon: Auth0Icon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import BitbucketIcon from '@material-ui/icons/FormatBold';
|
||||
import {
|
||||
BackstageIdentity,
|
||||
bitbucketAuthApiRef,
|
||||
@@ -37,7 +36,7 @@ export type BitbucketAuthResponse = {
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'bitbucket',
|
||||
title: 'Bitbucket',
|
||||
icon: BitbucketIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import GithubIcon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { GithubSession } from './types';
|
||||
import {
|
||||
@@ -48,7 +47,7 @@ export type GithubAuthResponse = {
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'github',
|
||||
title: 'GitHub',
|
||||
icon: GithubIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import GitlabIcon from '@material-ui/icons/AcUnit';
|
||||
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'gitlab',
|
||||
title: 'GitLab',
|
||||
icon: GitlabIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import GoogleIcon from '@material-ui/icons/AcUnit';
|
||||
import { googleAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'google',
|
||||
title: 'Google',
|
||||
icon: GoogleIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import MicrosoftIcon from '@material-ui/icons/AcUnit';
|
||||
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'microsoft',
|
||||
title: 'Microsoft',
|
||||
icon: MicrosoftIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import OAuth2Icon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
@@ -51,7 +50,7 @@ export type OAuth2Response = {
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'oauth2',
|
||||
title: 'Your Identity Provider',
|
||||
icon: OAuth2Icon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import OktaIcon from '@material-ui/icons/AcUnit';
|
||||
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
@@ -22,7 +21,7 @@ import { OAuthApiCreateOptions } from '../types';
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'okta',
|
||||
title: 'Okta',
|
||||
icon: OktaIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
const OKTA_OIDC_SCOPES: Set<String> = new Set([
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import OneLoginIcon from '@material-ui/icons/AcUnit';
|
||||
import {
|
||||
oneloginAuthApiRef,
|
||||
OAuthRequestApi,
|
||||
@@ -33,7 +32,7 @@ type CreateOptions = {
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'onelogin',
|
||||
title: 'onelogin',
|
||||
icon: OneLoginIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
const OIDC_SCOPES: Set<String> = new Set([
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import SamlIcon from '@material-ui/icons/AcUnit';
|
||||
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import {
|
||||
@@ -42,7 +41,7 @@ export type SamlAuthResponse = {
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'saml',
|
||||
title: 'SAML',
|
||||
icon: SamlIcon,
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+38
-33
@@ -20,11 +20,9 @@ import {
|
||||
renderWithEffects,
|
||||
withLogCollector,
|
||||
} from '@backstage/test-utils';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { defaultAppIcons } from './icons';
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
@@ -37,8 +35,8 @@ import {
|
||||
createRoutableExtension,
|
||||
analyticsApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { generateBoundRoutes, PrivateAppImpl } from './App';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
import { generateBoundRoutes, AppManager } from './AppManager';
|
||||
import { AppComponents, AppIcons } from './types';
|
||||
|
||||
describe('generateBoundRoutes', () => {
|
||||
it('runs happy path', () => {
|
||||
@@ -128,7 +126,7 @@ describe('Integration Test', () => {
|
||||
const HiddenComponent = plugin2.provide(
|
||||
createRoutableExtension({
|
||||
name: 'HiddenComponent',
|
||||
component: () => Promise.resolve((_: { path?: string }) => <div />),
|
||||
component: () => Promise.resolve(() => <div />),
|
||||
mountPoint: plugin2RouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -137,7 +135,7 @@ describe('Integration Test', () => {
|
||||
createRoutableExtension({
|
||||
name: 'ExposedComponent',
|
||||
component: () =>
|
||||
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
|
||||
Promise.resolve(() => {
|
||||
const link1 = useRouteRef(plugin1RouteRef);
|
||||
const link2 = useRouteRef(plugin2RouteRef);
|
||||
const subLink1 = useRouteRef(subRouteRef1);
|
||||
@@ -178,17 +176,19 @@ describe('Integration Test', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const components = {
|
||||
const components: AppComponents = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: () => null,
|
||||
ThemeProvider: AppThemeProvider,
|
||||
ThemeProvider: ({ children }) => <>{children}</>,
|
||||
};
|
||||
|
||||
const icons = {} as AppIcons;
|
||||
|
||||
it('runs happy paths', async () => {
|
||||
const app = new PrivateAppImpl({
|
||||
const app = new AppManager({
|
||||
apis: [noOpAnalyticsApi],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
@@ -196,12 +196,13 @@ describe('Integration Test', () => {
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => <>{children}</>,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
icons,
|
||||
plugins: [],
|
||||
components,
|
||||
configLoader: async () => [],
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
@@ -219,8 +220,8 @@ describe('Integration Test', () => {
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo/:x" />
|
||||
<Route path="/" element={<ExposedComponent />} />
|
||||
<Route path="/foo/:x" element={<HiddenComponent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
@@ -242,7 +243,7 @@ describe('Integration Test', () => {
|
||||
});
|
||||
|
||||
it('runs happy paths without optional routes', async () => {
|
||||
const app = new PrivateAppImpl({
|
||||
const app = new AppManager({
|
||||
apis: [noOpAnalyticsApi],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
@@ -250,12 +251,13 @@ describe('Integration Test', () => {
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => <>{children}</>,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
icons,
|
||||
plugins: [],
|
||||
components,
|
||||
configLoader: async () => [],
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
@@ -271,8 +273,8 @@ describe('Integration Test', () => {
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo" />
|
||||
<Route path="/" element={<ExposedComponent />} />
|
||||
<Route path="/foo" element={<HiddenComponent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
@@ -299,7 +301,7 @@ describe('Integration Test', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
const app = new AppManager({
|
||||
apis,
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
@@ -307,10 +309,10 @@ describe('Integration Test', () => {
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => <>{children}</>,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
icons,
|
||||
plugins: [
|
||||
createPlugin({
|
||||
id: 'test',
|
||||
@@ -318,6 +320,7 @@ describe('Integration Test', () => {
|
||||
}),
|
||||
],
|
||||
components,
|
||||
configLoader: async () => [],
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
@@ -333,8 +336,8 @@ describe('Integration Test', () => {
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo" />
|
||||
<Route path="/" element={<ExposedComponent />} />
|
||||
<Route path="/foo" element={<HiddenComponent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
@@ -349,7 +352,7 @@ describe('Integration Test', () => {
|
||||
it('should track route changes via analytics api', async () => {
|
||||
const mockAnalyticsApi = new MockAnalyticsApi();
|
||||
const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)];
|
||||
const app = new PrivateAppImpl({
|
||||
const app = new AppManager({
|
||||
apis,
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
@@ -357,12 +360,13 @@ describe('Integration Test', () => {
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => <>{children}</>,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
icons,
|
||||
plugins: [],
|
||||
components,
|
||||
configLoader: async () => [],
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
@@ -379,7 +383,7 @@ describe('Integration Test', () => {
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<NavigateComponent />} />
|
||||
<Route path="/foo" element={<HiddenComponent path="/foo" />} />
|
||||
<Route path="/foo" element={<HiddenComponent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
@@ -409,7 +413,7 @@ describe('Integration Test', () => {
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
const app = new PrivateAppImpl({
|
||||
const app = new AppManager({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
@@ -417,12 +421,13 @@ describe('Integration Test', () => {
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
Provider: ({ children }) => <>{children}</>,
|
||||
},
|
||||
],
|
||||
icons: defaultAppIcons,
|
||||
icons,
|
||||
plugins: [],
|
||||
components,
|
||||
configLoader: async () => [],
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
@@ -439,9 +444,9 @@ describe('Integration Test', () => {
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/test/:thing">
|
||||
<HiddenComponent path="/some/:thing" />
|
||||
</ExposedComponent>
|
||||
<Route path="/test/:thing" element={<ExposedComponent />}>
|
||||
<Route path="/some/:thing" element={<HiddenComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
+14
-21
@@ -77,6 +77,8 @@ import {
|
||||
SignInPageProps,
|
||||
SignInResult,
|
||||
} from './types';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
import { defaultConfigLoader } from './defaultConfigLoader';
|
||||
|
||||
export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) {
|
||||
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
|
||||
@@ -121,17 +123,6 @@ function getBasePath(configApi: Config) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
type FullAppOptions = {
|
||||
apis: Iterable<AnyApiFactory>;
|
||||
icons: NonNullable<AppOptions['icons']>;
|
||||
plugins: BackstagePlugin<any, any>[];
|
||||
components: AppComponents;
|
||||
themes: AppTheme[];
|
||||
configLoader?: AppConfigLoader;
|
||||
defaultApis: Iterable<AnyApiFactory>;
|
||||
bindRoutes?: AppOptions['bindRoutes'];
|
||||
};
|
||||
|
||||
function useConfigLoader(
|
||||
configLoader: AppConfigLoader | undefined,
|
||||
components: AppComponents,
|
||||
@@ -151,7 +142,7 @@ function useConfigLoader(
|
||||
noConfigNode = <BootErrorPage step="load-config" error={config.error} />;
|
||||
}
|
||||
|
||||
const { ThemeProvider } = components;
|
||||
const { ThemeProvider = AppThemeProvider } = components;
|
||||
|
||||
// Before the config is loaded we can't use a router, so exit early
|
||||
if (noConfigNode) {
|
||||
@@ -170,7 +161,7 @@ function useConfigLoader(
|
||||
}
|
||||
|
||||
class AppContextImpl implements AppContext {
|
||||
constructor(private readonly app: PrivateAppImpl) {}
|
||||
constructor(private readonly app: AppManager) {}
|
||||
|
||||
getPlugins(): BackstagePlugin<any, any>[] {
|
||||
return this.app.getPlugins();
|
||||
@@ -185,7 +176,7 @@ class AppContextImpl implements AppContext {
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateAppImpl implements BackstageApp {
|
||||
export class AppManager implements BackstageApp {
|
||||
private apiHolder?: ApiHolder;
|
||||
private configApi?: ConfigApi;
|
||||
|
||||
@@ -201,14 +192,16 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
private readonly identityApi = new AppIdentity();
|
||||
private readonly apiFactoryRegistry: ApiFactoryRegistry;
|
||||
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
constructor(options: AppOptions) {
|
||||
this.apis = options.apis ?? [];
|
||||
this.icons = options.icons;
|
||||
this.plugins = new Set(options.plugins);
|
||||
this.plugins = new Set(
|
||||
(options.plugins as BackstagePlugin<any, any>[]) ?? [],
|
||||
);
|
||||
this.components = options.components;
|
||||
this.themes = options.themes;
|
||||
this.configLoader = options.configLoader;
|
||||
this.defaultApis = options.defaultApis;
|
||||
this.themes = options.themes as AppTheme[];
|
||||
this.configLoader = options.configLoader ?? defaultConfigLoader;
|
||||
this.defaultApis = options.defaultApis ?? [];
|
||||
this.bindRoutes = options.bindRoutes;
|
||||
this.apiFactoryRegistry = new ApiFactoryRegistry();
|
||||
}
|
||||
@@ -307,7 +300,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
return loadedConfig.node;
|
||||
}
|
||||
|
||||
const { ThemeProvider } = this.components;
|
||||
const { ThemeProvider = AppThemeProvider } = this.components;
|
||||
|
||||
return (
|
||||
<ApiProvider apis={this.getApiHolder()}>
|
||||
@@ -90,6 +90,17 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) {
|
||||
throw new Error('App has no themes');
|
||||
}
|
||||
|
||||
if (appTheme.Provider) {
|
||||
return <appTheme.Provider children={children} />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " +
|
||||
'and should be migrated to use a Provider instead. ' +
|
||||
'See https://backstage.io/docs/api/deprecations#app-theme for more info.',
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={appTheme.theme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
|
||||
@@ -14,171 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { ErrorPage, ErrorPanel, Progress } from '@backstage/core-components';
|
||||
import { darkTheme, lightTheme } from '@backstage/theme';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import {
|
||||
BrowserRouter,
|
||||
MemoryRouter,
|
||||
useInRouterContext,
|
||||
} from 'react-router-dom';
|
||||
import { PrivateAppImpl } from './App';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
import { defaultApis } from './defaultApis';
|
||||
import { defaultAppIcons } from './icons';
|
||||
import {
|
||||
AppConfigLoader,
|
||||
AppOptions,
|
||||
BootErrorPageProps,
|
||||
ErrorBoundaryFallbackProps,
|
||||
} from './types';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* The default config loader, which expects that config is available at compile-time
|
||||
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
|
||||
* returned by the config loader.
|
||||
*
|
||||
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
|
||||
* which can be rewritten at runtime to contain an additional JSON config object.
|
||||
* If runtime config is present, it will be placed first in the config array, overriding
|
||||
* other config values.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const defaultConfigLoader: AppConfigLoader = async (
|
||||
// This string may be replaced at runtime to provide additional config.
|
||||
// It should be replaced by a JSON-serialized config object.
|
||||
// It's a param so we can test it, but at runtime this will always fall back to default.
|
||||
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
|
||||
) => {
|
||||
const appConfig = process.env.APP_CONFIG;
|
||||
if (!appConfig) {
|
||||
throw new Error('No static configuration provided');
|
||||
}
|
||||
if (!Array.isArray(appConfig)) {
|
||||
throw new Error('Static configuration has invalid format');
|
||||
}
|
||||
const configs = appConfig.slice() as unknown as AppConfig[];
|
||||
|
||||
// Avoiding this string also being replaced at runtime
|
||||
if (
|
||||
runtimeConfigJson !==
|
||||
'__app_injected_runtime_config__'.toLocaleUpperCase('en-US')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(runtimeConfigJson) as JsonObject;
|
||||
if (Array.isArray(data)) {
|
||||
configs.push(...data);
|
||||
} else {
|
||||
configs.push({ data, context: 'env' });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load runtime configuration, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const windowAppConfig = (window as any).__APP_CONFIG__;
|
||||
if (windowAppConfig) {
|
||||
configs.push({
|
||||
context: 'window',
|
||||
data: windowAppConfig,
|
||||
});
|
||||
}
|
||||
return configs;
|
||||
};
|
||||
|
||||
export function OptionallyWrapInRouter({ children }: PropsWithChildren<{}>) {
|
||||
if (useInRouterContext()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <MemoryRouter>{children}</MemoryRouter>;
|
||||
}
|
||||
createApp as createDefaultApp,
|
||||
OptionalAppOptions,
|
||||
} from '@backstage/app-defaults';
|
||||
|
||||
/**
|
||||
* Creates a new Backstage App.
|
||||
*
|
||||
* @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead
|
||||
* @param options - A set of options for creating the app
|
||||
* @public
|
||||
*/
|
||||
export function createApp(options?: AppOptions) {
|
||||
const DefaultNotFoundPage = () => (
|
||||
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
|
||||
export function createApp(options?: OptionalAppOptions) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' +
|
||||
'migrate to importing createApp from the @backstage/app-defaults package instead. ' +
|
||||
'If you do not wish to use a standard app configuration but instead supply all options yourself ' +
|
||||
' you can use createSpecializedApp from @backstage/core-app-api instead.',
|
||||
);
|
||||
const DefaultBootErrorPage = ({ step, error }: BootErrorPageProps) => {
|
||||
let message = '';
|
||||
if (step === 'load-config') {
|
||||
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
|
||||
} else if (step === 'load-chunk') {
|
||||
message = `Lazy loaded chunk failed to load, try to reload the page: ${error.message}`;
|
||||
}
|
||||
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
|
||||
return (
|
||||
<OptionallyWrapInRouter>
|
||||
<ErrorPage status="501" statusMessage={message} />
|
||||
</OptionallyWrapInRouter>
|
||||
);
|
||||
};
|
||||
const DefaultErrorBoundaryFallback = ({
|
||||
error,
|
||||
resetError,
|
||||
plugin,
|
||||
}: ErrorBoundaryFallbackProps) => {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={`Error in ${plugin?.getId()}`}
|
||||
defaultExpanded
|
||||
error={error}
|
||||
>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const apis = options?.apis ?? [];
|
||||
const icons = { ...defaultAppIcons, ...options?.icons };
|
||||
const plugins = options?.plugins ?? [];
|
||||
const components = {
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
Router: BrowserRouter,
|
||||
ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
|
||||
ThemeProvider: AppThemeProvider,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
icon: <LightIcon />,
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
title: 'Dark Theme',
|
||||
variant: 'dark',
|
||||
theme: darkTheme,
|
||||
icon: <DarkIcon />,
|
||||
},
|
||||
];
|
||||
const configLoader = options?.configLoader ?? defaultConfigLoader;
|
||||
|
||||
return new PrivateAppImpl({
|
||||
apis,
|
||||
icons,
|
||||
plugins: plugins as BackstagePlugin<any, any>[],
|
||||
components,
|
||||
themes,
|
||||
configLoader,
|
||||
defaultApis,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
});
|
||||
return createDefaultApp(options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { AppManager } from './AppManager';
|
||||
import { AppOptions, BackstageApp } from './types';
|
||||
|
||||
/**
|
||||
* Creates a new Backstage App where the full set of options are required.
|
||||
*
|
||||
* @public
|
||||
* @param options - A set of options for creating the app
|
||||
* @returns
|
||||
*/
|
||||
export function createSpecializedApp(options: AppOptions): BackstageApp {
|
||||
return new AppManager(options);
|
||||
}
|
||||
+1
-50
@@ -14,15 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderWithEffects } from '@backstage/test-utils';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
defaultConfigLoader,
|
||||
OptionallyWrapInRouter,
|
||||
createApp,
|
||||
} from './createApp';
|
||||
import { defaultConfigLoader } from './defaultConfigLoader';
|
||||
|
||||
(process as any).env = { NODE_ENV: 'test' };
|
||||
const anyEnv = process.env as any;
|
||||
@@ -100,44 +92,3 @@ describe('defaultConfigLoader', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptionallyWrapInRouter', () => {
|
||||
it('should wrap with router if not yet inside a router', async () => {
|
||||
const { getByText } = render(
|
||||
<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>,
|
||||
);
|
||||
|
||||
expect(getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not wrap with router if already inside a router', async () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter>
|
||||
<OptionallyWrapInRouter>Test</OptionallyWrapInRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Optional ThemeProvider', () => {
|
||||
it('should render app with user-provided ThemeProvider', async () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: MemoryRouter,
|
||||
ErrorBoundaryFallback: () => null,
|
||||
ThemeProvider: ({ children }: PropsWithChildren<{}>) => (
|
||||
<main role="main">{children}</main>
|
||||
),
|
||||
};
|
||||
|
||||
const App = createApp({ components }).getProvider();
|
||||
|
||||
await renderWithEffects(<App />);
|
||||
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user