Merge branch 'master' into migrate/oss
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -29,7 +29,7 @@ import LogoIcon from './LogoIcon';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
|
||||
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
|
||||
import { SidebarSearch } from '@backstage/plugin-search';
|
||||
import { SidebarSearchModal } from '@backstage/plugin-search';
|
||||
import { Shortcuts } from '@backstage/plugin-shortcuts';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -79,7 +79,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SidebarSearch />
|
||||
<SidebarSearchModal />
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/backend-common
|
||||
|
||||
## 0.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c4cad0bf2: AWSS3UrlReader now throws a `NotModifiedError` (exported from @backstage/backend-common) when s3 returns a 304 response.
|
||||
- 0611f3b3e2: Reading app config from a remote server
|
||||
- Updated dependencies
|
||||
- @backstage/config-loader@0.7.2
|
||||
|
||||
## 0.9.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.9",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/cli-common": "^0.1.5",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/config-loader": "^0.7.1",
|
||||
"@backstage/config-loader": "^0.7.2",
|
||||
"@backstage/errors": "^0.1.4",
|
||||
"@backstage/integration": "^0.6.9",
|
||||
"@backstage/types": "^0.1.1",
|
||||
@@ -79,8 +79,8 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.1",
|
||||
"@backstage/test-utils": "^0.1.20",
|
||||
"@backstage/cli": "^0.8.2",
|
||||
"@backstage/test-utils": "^0.1.21",
|
||||
"@types/archiver": "^5.1.0",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/concat-stream": "^1.6.0",
|
||||
|
||||
@@ -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: {}
|
||||
@@ -1,5 +1,20 @@
|
||||
# example-backend
|
||||
|
||||
## 0.2.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-common@0.9.9
|
||||
- @backstage/plugin-jenkins-backend@0.1.7
|
||||
- @backstage/plugin-search-backend-module-elasticsearch@0.0.5
|
||||
- @backstage/plugin-scaffolder-backend@0.15.12
|
||||
- @backstage/plugin-azure-devops-backend@0.2.0
|
||||
- @backstage/catalog-client@0.5.1
|
||||
- @backstage/plugin-auth-backend@0.4.7
|
||||
- @backstage/plugin-catalog-backend@0.17.3
|
||||
- @backstage/plugin-scaffolder-backend-module-rails@0.1.7
|
||||
|
||||
## 0.2.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend",
|
||||
"version": "0.2.50",
|
||||
"version": "0.2.52",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -24,28 +24,28 @@
|
||||
"migrate:create": "knex migrate:make -x ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.7",
|
||||
"@backstage/catalog-client": "^0.5.0",
|
||||
"@backstage/backend-common": "^0.9.9",
|
||||
"@backstage/catalog-client": "^0.5.1",
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/integration": "^0.6.8",
|
||||
"@backstage/plugin-app-backend": "^0.3.17",
|
||||
"@backstage/plugin-auth-backend": "^0.4.5",
|
||||
"@backstage/plugin-azure-devops-backend": "^0.1.3",
|
||||
"@backstage/plugin-auth-backend": "^0.4.7",
|
||||
"@backstage/plugin-azure-devops-backend": "^0.2.0",
|
||||
"@backstage/plugin-badges-backend": "^0.1.11",
|
||||
"@backstage/plugin-catalog-backend": "^0.17.1",
|
||||
"@backstage/plugin-catalog-backend": "^0.17.3",
|
||||
"@backstage/plugin-code-coverage-backend": "^0.1.14",
|
||||
"@backstage/plugin-graphql-backend": "^0.1.9",
|
||||
"@backstage/plugin-jenkins-backend": "^0.1.6",
|
||||
"@backstage/plugin-jenkins-backend": "^0.1.7",
|
||||
"@backstage/plugin-kubernetes-backend": "^0.3.18",
|
||||
"@backstage/plugin-kafka-backend": "^0.2.10",
|
||||
"@backstage/plugin-proxy-backend": "^0.2.13",
|
||||
"@backstage/plugin-rollbar-backend": "^0.1.15",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.10",
|
||||
"@backstage/plugin-scaffolder-backend-module-rails": "^0.1.5",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.12",
|
||||
"@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7",
|
||||
"@backstage/plugin-search-backend": "^0.2.6",
|
||||
"@backstage/plugin-search-backend-node": "^0.4.2",
|
||||
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.4",
|
||||
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5",
|
||||
"@backstage/plugin-search-backend-module-pg": "^0.2.1",
|
||||
"@backstage/plugin-techdocs-backend": "^0.10.5",
|
||||
"@backstage/plugin-tech-insights-backend": "^0.1.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.0",
|
||||
"@backstage/cli": "^0.8.2",
|
||||
"@types/dockerode": "^3.2.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-serve-static-core": "^4.17.5"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @backstage/catalog-client
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 39e92897e4: Improved API documentation for catalog-client.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/catalog-client",
|
||||
"description": "An isomorphic client for the catalog backend",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/errors": "^0.1.4",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.0",
|
||||
"@backstage/cli": "^0.8.2",
|
||||
"@types/jest": "^26.0.7",
|
||||
"msw": "^0.35.0"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @backstage/cli
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd355bca46: Switched to dynamically determining the packages that are unsafe to repack when executing the CLI within the Backstage main repo.
|
||||
- b393c4d4be: Fixed the `config:check` command that was incorrectly only validating frontend configuration. Also added a `--frontend` flag to the command which maintains that behavior.
|
||||
- 0611f3b3e2: Reading app config from a remote server
|
||||
- ec64d9590c: Make `ExitCodeError` call `super` early to avoid compiler warnings
|
||||
- 8af66229e7: Bumped `@spotify/eslint-config-react` from `v10` to `v12`, dropping support for Node.js v12.
|
||||
- a197708da9: Bumped `@spotify/eslint-config-typescript` from `v10` to `v12`, dropping support for Node.js v12.
|
||||
- Updated dependencies
|
||||
- @backstage/config-loader@0.7.2
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -30,19 +30,19 @@
|
||||
"dependencies": {
|
||||
"@backstage/cli-common": "^0.1.5",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/config-loader": "^0.7.1",
|
||||
"@backstage/config-loader": "^0.7.2",
|
||||
"@backstage/errors": "^0.1.4",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@hot-loader/react-dom": "^16.13.0",
|
||||
"@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",
|
||||
"@spotify/eslint-config-base": "^12.0.0",
|
||||
"@spotify/eslint-config-react": "^10.0.0",
|
||||
"@spotify/eslint-config-react": "^12.0.0",
|
||||
"@spotify/eslint-config-typescript": "^12.0.0",
|
||||
"@sucrase/jest-plugin": "^2.1.1",
|
||||
"@sucrase/webpack-loader": "^2.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",
|
||||
@@ -117,14 +117,14 @@
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "^0.9.8",
|
||||
"@backstage/backend-common": "^0.9.9",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/core-components": "^0.7.2",
|
||||
"@backstage/core-plugin-api": "^0.1.12",
|
||||
"@backstage/core-app-api": "^0.1.19",
|
||||
"@backstage/core-components": "^0.7.3",
|
||||
"@backstage/core-plugin-api": "^0.1.13",
|
||||
"@backstage/core-app-api": "^0.1.20",
|
||||
"@backstage/dev-utils": "^0.2.12",
|
||||
"@backstage/test-utils": "^0.1.20",
|
||||
"@backstage/theme": "^0.2.12",
|
||||
"@backstage/test-utils": "^0.1.21",
|
||||
"@backstage/theme": "^0.2.13",
|
||||
"@types/diff": "^5.0.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
|
||||
@@ -25,6 +25,7 @@ export default async (cmd: Command) => {
|
||||
args: cmd.config,
|
||||
fromPackage: cmd.package,
|
||||
mockEnv: cmd.lax,
|
||||
fullVisibility: !cmd.frontend,
|
||||
});
|
||||
const visibility = getVisibilityOption(cmd);
|
||||
const data = serializeConfigData(appConfigs, schema, visibility);
|
||||
|
||||
@@ -22,5 +22,6 @@ export default async (cmd: Command) => {
|
||||
args: cmd.config,
|
||||
fromPackage: cmd.package,
|
||||
mockEnv: cmd.lax,
|
||||
fullVisibility: !cmd.frontend,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -263,7 +263,6 @@ export default async (cmd: Command) => {
|
||||
const pluginDir = isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', pluginId)
|
||||
: paths.resolveTargetRoot(pluginId);
|
||||
const ownerIds = parseOwnerIds(answers.owner);
|
||||
const { version: pluginVersion } = isMonoRepo
|
||||
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
|
||||
: { version: '0.1.0' };
|
||||
@@ -318,12 +317,8 @@ export default async (cmd: Command) => {
|
||||
await addPluginExtensionToApp(pluginId, extensionName, name);
|
||||
}
|
||||
|
||||
if (ownerIds && ownerIds.length) {
|
||||
await addCodeownersEntry(
|
||||
codeownersPath!,
|
||||
`/plugins/${pluginId}`,
|
||||
ownerIds,
|
||||
);
|
||||
if (answers.owner) {
|
||||
await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner);
|
||||
}
|
||||
|
||||
Task.log();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { join as joinPath } from 'path';
|
||||
import { Command } from 'commander';
|
||||
import { FactoryRegistry } from '../../lib/create/FactoryRegistry';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { Task } from '../../lib/tasks';
|
||||
|
||||
function parseOptions(optionStrings: string[]): Record<string, string> {
|
||||
const options: Record<string, string> = {};
|
||||
|
||||
for (const str of optionStrings) {
|
||||
const [key] = str.split('=', 1);
|
||||
const value = str.slice(key.length + 1);
|
||||
if (!key || str[key.length] !== '=') {
|
||||
throw new Error(
|
||||
`Invalid option '${str}', must be of the format <key>=<value>`,
|
||||
);
|
||||
}
|
||||
options[key] = value;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const cmdOpts = cmd.opts();
|
||||
|
||||
const factory = await FactoryRegistry.interactiveSelect(cmdOpts.select);
|
||||
|
||||
const providedOptions = parseOptions(cmdOpts.option);
|
||||
const options = await FactoryRegistry.populateOptions(
|
||||
factory,
|
||||
providedOptions,
|
||||
);
|
||||
|
||||
let isMonoRepo = false;
|
||||
try {
|
||||
const rootPackageJson = await fs.readJson(
|
||||
paths.resolveTargetRoot('package.json'),
|
||||
);
|
||||
if (rootPackageJson.workspaces) {
|
||||
isMonoRepo = true;
|
||||
}
|
||||
} catch (error) {
|
||||
assertError(error);
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultVersion = '0.1.0';
|
||||
try {
|
||||
const rootLernaJson = await fs.readJson(
|
||||
paths.resolveTargetRoot('lerna.json'),
|
||||
);
|
||||
if (rootLernaJson.version) {
|
||||
defaultVersion = rootLernaJson.version;
|
||||
}
|
||||
} catch (error) {
|
||||
assertError(error);
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const tempDirs = new Array<string>();
|
||||
async function createTemporaryDirectory(name: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(joinPath(os.tmpdir(), name));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
try {
|
||||
await factory.create(options, {
|
||||
isMonoRepo,
|
||||
defaultVersion,
|
||||
scope: cmdOpts.scope?.replace(/^@/, ''),
|
||||
npmRegistry: cmdOpts.npmRegistry,
|
||||
private: Boolean(cmdOpts.private),
|
||||
createTemporaryDirectory,
|
||||
markAsModified() {
|
||||
modified = true;
|
||||
},
|
||||
});
|
||||
|
||||
Task.log();
|
||||
Task.log(`🎉 Successfully created ${factory.name}`);
|
||||
Task.log();
|
||||
} catch (error) {
|
||||
assertError(error);
|
||||
Task.error(error.message);
|
||||
|
||||
if (modified) {
|
||||
Task.log('It seems that something went wrong in the creation process 🤔');
|
||||
Task.log();
|
||||
Task.log(
|
||||
'We have left the changes that were made intact in case you want to',
|
||||
);
|
||||
Task.log(
|
||||
'continue manually, but you can also revert the changes and try again.',
|
||||
);
|
||||
|
||||
Task.error(`🔥 Failed to create ${factory.name}!`);
|
||||
}
|
||||
} finally {
|
||||
for (const dir of tempDirs) {
|
||||
try {
|
||||
await fs.remove(dir);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to remove temporary directory '${dir}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -75,6 +75,30 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.option(...configOption)
|
||||
.action(lazy(() => import('./backend/dev').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.storeOptionsAsProperties(false)
|
||||
.description(
|
||||
'Open up an interactive guide to creating new things in your app',
|
||||
)
|
||||
.option(
|
||||
'--select <name>',
|
||||
'Select the thing you want to be creating upfront',
|
||||
)
|
||||
.option(
|
||||
'--option <name>=<value>',
|
||||
'Pre-fill options for the creation process',
|
||||
(opt, arr: string[]) => [...arr, opt],
|
||||
[],
|
||||
)
|
||||
.option('--scope <scope>', 'The scope to use for new packages')
|
||||
.option(
|
||||
'--npm-registry <URL>',
|
||||
'The package registry to use for new packages',
|
||||
)
|
||||
.option('--no-private', 'Do not mark new packages as private')
|
||||
.action(lazy(() => import('./create/create').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create-plugin')
|
||||
.option(
|
||||
@@ -172,6 +196,7 @@ export function registerCommands(program: CommanderStatic) {
|
||||
'Only load config schema that applies to the given package',
|
||||
)
|
||||
.option('--lax', 'Do not require environment variables to be set')
|
||||
.option('--frontend', 'Only validate the frontend configuration')
|
||||
.option(...configOption)
|
||||
.description(
|
||||
'Validate that the given configuration loads and matches schema',
|
||||
|
||||
@@ -177,9 +177,9 @@ describe('removePlugin', () => {
|
||||
fse.readFileSync(mockedCodeownersPath, 'utf8'),
|
||||
);
|
||||
await addCodeownersEntry(
|
||||
testFilePath!,
|
||||
path.join('plugins', testPluginName),
|
||||
['@thisIsAtestTeam', 'test@gmail.com'],
|
||||
'@thisIsAtestTeam test@gmail.com',
|
||||
testFilePath,
|
||||
);
|
||||
await removePluginFromCodeOwners(testFilePath, testPluginName);
|
||||
expect(testFileContent).toBe(codeOwnersFileContent);
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { paths } from '../paths';
|
||||
|
||||
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
|
||||
const USER_ID_RE = /^@[-\w]+$/;
|
||||
@@ -30,14 +31,14 @@ type CodeownersEntry = {
|
||||
export async function getCodeownersFilePath(
|
||||
rootDir: string,
|
||||
): Promise<string | undefined> {
|
||||
const paths = [
|
||||
const possiblePaths = [
|
||||
path.join(rootDir, '.github', 'CODEOWNERS'),
|
||||
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
|
||||
path.join(rootDir, 'docs', 'CODEOWNERS'),
|
||||
path.join(rootDir, 'CODEOWNERS'),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
for (const p of possiblePaths) {
|
||||
if (await fs.pathExists(p)) {
|
||||
return p;
|
||||
}
|
||||
@@ -55,7 +56,7 @@ export function isValidSingleOwnerId(id: string): boolean {
|
||||
}
|
||||
|
||||
export function parseOwnerIds(
|
||||
spaceSeparatedOwnerIds: string,
|
||||
spaceSeparatedOwnerIds: string | undefined,
|
||||
): string[] | undefined {
|
||||
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
|
||||
return undefined;
|
||||
@@ -70,11 +71,24 @@ export function parseOwnerIds(
|
||||
}
|
||||
|
||||
export async function addCodeownersEntry(
|
||||
codeownersFilePath: string,
|
||||
ownedPath: string,
|
||||
ownerIds: string[],
|
||||
): Promise<void> {
|
||||
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
|
||||
ownerStr: string,
|
||||
codeownersFilePath?: string,
|
||||
): Promise<boolean> {
|
||||
const ownerIds = parseOwnerIds(ownerStr);
|
||||
if (!ownerIds || ownerIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let filePath = codeownersFilePath;
|
||||
if (!filePath) {
|
||||
filePath = await getCodeownersFilePath(paths.targetRoot);
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allLines = (await fs.readFile(filePath, 'utf8')).split('\n');
|
||||
|
||||
// Only keep comments from the top of the file
|
||||
const commentLines = [];
|
||||
@@ -117,5 +131,7 @@ export async function addCodeownersEntry(
|
||||
|
||||
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
|
||||
|
||||
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
|
||||
await fs.writeFile(filePath, newLines.join('\n'), 'utf8');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Options = {
|
||||
fromPackage?: string;
|
||||
mockEnv?: boolean;
|
||||
withFilteredKeys?: boolean;
|
||||
fullVisibility?: boolean;
|
||||
};
|
||||
|
||||
export async function loadCliConfig(options: Options) {
|
||||
@@ -70,7 +71,9 @@ export async function loadCliConfig(options: Options) {
|
||||
|
||||
try {
|
||||
const frontendAppConfigs = schema.process(appConfigs, {
|
||||
visibility: ['frontend'],
|
||||
visibility: options.fullVisibility
|
||||
? ['frontend', 'backend', 'secret']
|
||||
: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { AnyFactory, Prompt } from './types';
|
||||
import * as factories from './factories';
|
||||
import partition from 'lodash/partition';
|
||||
|
||||
function applyPromptMessageTransforms<T>(
|
||||
prompt: Prompt<T>,
|
||||
transforms: {
|
||||
message: (msg: string) => string;
|
||||
error: (msg: string) => string;
|
||||
},
|
||||
): Prompt<T> {
|
||||
return {
|
||||
...prompt,
|
||||
message:
|
||||
prompt.message &&
|
||||
(async answers => {
|
||||
if (typeof prompt.message === 'function') {
|
||||
return transforms.message(await prompt.message(answers));
|
||||
}
|
||||
return transforms.message(await prompt.message!);
|
||||
}),
|
||||
validate:
|
||||
prompt.validate &&
|
||||
(async (...args) => {
|
||||
const result = await prompt.validate!(...args);
|
||||
if (typeof result === 'string') {
|
||||
return transforms.error(result);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class FactoryRegistry {
|
||||
private static factoryMap = new Map<string, AnyFactory>(
|
||||
Object.values(factories).map(factory => [factory.name, factory]),
|
||||
);
|
||||
|
||||
static async interactiveSelect(preselected?: string): Promise<AnyFactory> {
|
||||
let selected = preselected;
|
||||
|
||||
if (!selected) {
|
||||
const answers = await inquirer.prompt<{ name: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'name',
|
||||
message: 'What do you want to create?',
|
||||
choices: Array.from(this.factoryMap.values()).map(factory => ({
|
||||
name: `${factory.name} - ${factory.description}`,
|
||||
value: factory.name,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
selected = answers.name;
|
||||
}
|
||||
|
||||
const factory = this.factoryMap.get(selected);
|
||||
if (!factory) {
|
||||
throw new Error(`Unknown selection '${selected}'`);
|
||||
}
|
||||
return factory;
|
||||
}
|
||||
|
||||
static async populateOptions(
|
||||
factory: AnyFactory,
|
||||
provided: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
let currentOptions = provided;
|
||||
|
||||
if (factory.optionsDiscovery) {
|
||||
const discoveredOptions = await factory.optionsDiscovery();
|
||||
currentOptions = {
|
||||
...currentOptions,
|
||||
...(discoveredOptions as Record<string, string>),
|
||||
};
|
||||
}
|
||||
|
||||
if (factory.optionsPrompts) {
|
||||
const [hasAnswers, needsAnswers] = partition(
|
||||
factory.optionsPrompts,
|
||||
option => option.name in currentOptions,
|
||||
);
|
||||
|
||||
for (const option of hasAnswers) {
|
||||
const value = provided[option.name];
|
||||
|
||||
if (option.validate) {
|
||||
const result = option.validate(value);
|
||||
if (result !== true) {
|
||||
throw new Error(`Invalid option '${option.name}'. ${result}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentOptions = await inquirer.prompt(
|
||||
needsAnswers.map(option =>
|
||||
applyPromptMessageTransforms(option, {
|
||||
message: chalk.blue,
|
||||
error: chalk.red,
|
||||
}),
|
||||
),
|
||||
currentOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return currentOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { sep, resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../paths';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import { createMockOutputStream, mockPaths } from './common/testUtils';
|
||||
import { backendPlugin } from './backendPlugin';
|
||||
|
||||
describe('backendPlugin factory', () => {
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: '/root',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a backend plugin', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
packages: {
|
||||
backend: {
|
||||
'package.json': JSON.stringify({}),
|
||||
},
|
||||
},
|
||||
plugins: mockFs.directory(),
|
||||
},
|
||||
[paths.resolveOwn('templates')]: mockFs.load(
|
||||
paths.resolveOwn('templates'),
|
||||
),
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(backendPlugin, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await backendPlugin.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expect(output).toEqual([
|
||||
'',
|
||||
'Creating backend plugin backstage-plugin-test-backend',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-backend`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'copying tsconfig.json',
|
||||
'copying index.ts',
|
||||
'templating run.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'copying router.test.ts',
|
||||
'copying router.ts',
|
||||
'templating standaloneServer.ts.hbs',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-backend`,
|
||||
'backend adding dependency',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson('/root/packages/backend/package.json'),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-test-backend': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: resolvePath('/root/plugins/test-backend'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: resolvePath('/root/plugins/test-backend'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { createFactory, CreateContext } from '../types';
|
||||
import { addPackageDependency, Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const backendPlugin = createFactory<Options>({
|
||||
name: 'backend-plugin',
|
||||
description: 'A new backend plugin',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const id = `${options.id}-backend`;
|
||||
const name = ctx.scope
|
||||
? `@${ctx.scope}/plugin-${id}`
|
||||
: `backstage-plugin-${id}`;
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', id)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-backend-plugin',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
pluginVar: `${camelCase(id)}Plugin`,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
},
|
||||
});
|
||||
|
||||
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
|
||||
await Task.forItem('backend', 'adding dependency', async () => {
|
||||
await addPackageDependency(
|
||||
paths.resolveTargetRoot('packages/backend/package.json'),
|
||||
{
|
||||
dependencies: {
|
||||
[name]: `^${ctx.defaultVersion}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${id}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { Prompt } from '../../types';
|
||||
import { parseOwnerIds } from '../../../codeowners';
|
||||
|
||||
export function pluginIdPrompt(): Prompt<{ id: string }> {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'id',
|
||||
message: 'Enter the ID of the plugin [required]',
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'Please enter the ID of the plugin';
|
||||
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
||||
return 'Plugin IDs must be lowercase and contain only letters, digits, and dashes.';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ownerPrompt(): Prompt<{
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
}> {
|
||||
return {
|
||||
type: 'input',
|
||||
name: 'owner',
|
||||
message: 'Enter an owner to add to CODEOWNERS [optional]',
|
||||
when: opts => Boolean(opts.codeOwnersPath),
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ownerIds = parseOwnerIds(value);
|
||||
if (!ownerIds) {
|
||||
return 'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses (e.g. user@example.com).';
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { sep } from 'path';
|
||||
import { createMockOutputStream, mockPaths } from './testUtils';
|
||||
import { CreateContext } from '../../types';
|
||||
import { executePluginPackageTemplate } from './tasks';
|
||||
|
||||
mockPaths({
|
||||
ownDir: '/own',
|
||||
targetRoot: '/root',
|
||||
});
|
||||
|
||||
describe('executePluginPackageTemplate', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute template', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
'yarn.lock': `
|
||||
some-package@^1.1.0:
|
||||
version "1.5.0"
|
||||
`,
|
||||
},
|
||||
'/own': {
|
||||
templates: {
|
||||
'test-template': {
|
||||
'package.json.hbs': `
|
||||
{
|
||||
"name": "my-{{id}}-plugin",
|
||||
{{#if makePrivate}}
|
||||
"private": true,
|
||||
{{/if}}
|
||||
"description": "testing",
|
||||
"dependencies": {
|
||||
"some-package": "{{ versionQuery 'some-package' '1.3.0' }}",
|
||||
"other-package": "{{ versionQuery 'other-package' '2.3.0' }}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
subdir: {
|
||||
'templated.txt.hbs': 'Hello {{id}}!',
|
||||
'not-templated.txt': 'Hello {{id}}!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
|
||||
let modified = false;
|
||||
await executePluginPackageTemplate(
|
||||
{
|
||||
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
} as CreateContext,
|
||||
{
|
||||
templateName: 'test-template',
|
||||
targetDir: '/target',
|
||||
values: {
|
||||
id: 'testing',
|
||||
makePrivate: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(modified).toBe(true);
|
||||
expect(output).toEqual([
|
||||
'Checking Prerequisites:',
|
||||
`availability ..${sep}target`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'templating package.json.hbs',
|
||||
'copying not-templated.txt',
|
||||
'templating templated.txt.hbs',
|
||||
'Installing:',
|
||||
`moving ..${sep}target`,
|
||||
]);
|
||||
await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{
|
||||
"name": "my-testing-plugin",
|
||||
"private": true,
|
||||
"description": "testing",
|
||||
"dependencies": {
|
||||
"some-package": "^1.1.0",
|
||||
"other-package": "^2.3.0"
|
||||
}
|
||||
}
|
||||
`);
|
||||
await expect(
|
||||
fs.readFile('/target/subdir/templated.txt', 'utf8'),
|
||||
).resolves.toBe('Hello testing!');
|
||||
await expect(
|
||||
fs.readFile('/target/subdir/not-templated.txt', 'utf8'),
|
||||
).resolves.toBe('Hello {{id}}!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'path';
|
||||
import { paths } from '../../../paths';
|
||||
import { Task, templatingTask } from '../../../tasks';
|
||||
import { Lockfile } from '../../../versioning';
|
||||
import { createPackageVersionProvider } from '../../../version';
|
||||
import { CreateContext } from '../../types';
|
||||
|
||||
export async function executePluginPackageTemplate(
|
||||
ctx: CreateContext,
|
||||
options: {
|
||||
templateName: string;
|
||||
targetDir: string;
|
||||
values: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const { targetDir } = options;
|
||||
|
||||
let lockfile: Lockfile | undefined;
|
||||
try {
|
||||
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
Task.section('Checking Prerequisites');
|
||||
const shortPluginDir = relativePath(paths.targetRoot, targetDir);
|
||||
await Task.forItem('availability', shortPluginDir, async () => {
|
||||
if (await fs.pathExists(targetDir)) {
|
||||
throw new Error(
|
||||
`A package with the same plugin ID already exists at ${chalk.cyan(
|
||||
shortPluginDir,
|
||||
)}. Please try again with a different ID.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const tempDir = await Task.forItem('creating', 'temp dir', async () => {
|
||||
return await ctx.createTemporaryDirectory('backstage-create');
|
||||
});
|
||||
|
||||
Task.section('Executing Template');
|
||||
await templatingTask(
|
||||
paths.resolveOwn('templates', options.templateName),
|
||||
tempDir,
|
||||
options.values,
|
||||
createPackageVersionProvider(lockfile),
|
||||
);
|
||||
|
||||
// Format package.json if it exists
|
||||
const pkgJsonPath = resolvePath(tempDir, 'package.json');
|
||||
if (await fs.pathExists(pkgJsonPath)) {
|
||||
const pkgJson = await fs.readJson(pkgJsonPath);
|
||||
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
Task.section('Installing');
|
||||
await Task.forItem('moving', shortPluginDir, async () => {
|
||||
await fs.move(tempDir, targetDir).catch(error => {
|
||||
throw new Error(
|
||||
`Failed to move package from ${tempDir} to ${targetDir}, ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ctx.markAsModified();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-control-regex */
|
||||
|
||||
import { WriteStream } from 'tty';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../../paths';
|
||||
|
||||
export function mockPaths(options: {
|
||||
ownDir?: string;
|
||||
ownRoot?: string;
|
||||
targetDir?: string;
|
||||
targetRoot?: string;
|
||||
}): void {
|
||||
const { ownDir, ownRoot, targetDir, targetRoot } = options;
|
||||
if (ownDir) {
|
||||
paths.ownDir = ownDir;
|
||||
jest
|
||||
.spyOn(paths, 'resolveOwn')
|
||||
.mockImplementation((...ps) => resolvePath(ownDir, ...ps));
|
||||
}
|
||||
if (ownRoot) {
|
||||
jest.spyOn(paths, 'ownRoot', 'get').mockReturnValue(ownRoot);
|
||||
jest
|
||||
.spyOn(paths, 'resolveOwnRoot')
|
||||
.mockImplementation((...ps) => resolvePath(ownRoot, ...ps));
|
||||
}
|
||||
if (targetDir) {
|
||||
paths.targetDir = targetDir;
|
||||
jest
|
||||
.spyOn(paths, 'resolveTarget')
|
||||
.mockImplementation((...ps) => resolvePath(targetDir, ...ps));
|
||||
}
|
||||
if (targetRoot) {
|
||||
jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(targetRoot);
|
||||
jest
|
||||
.spyOn(paths, 'resolveTargetRoot')
|
||||
.mockImplementation((...ps) => resolvePath(targetRoot, ...ps));
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockOutputStream() {
|
||||
const output = new Array<string>();
|
||||
return [
|
||||
output,
|
||||
{
|
||||
cursorTo: () => {},
|
||||
clearLine: () => {},
|
||||
moveCursor: () => {},
|
||||
write: (msg: string) => {
|
||||
let clean = msg;
|
||||
// Remove terminal color escape sequences
|
||||
clean = clean.replace(/\x1B\[\d\dm/g, '');
|
||||
// Remove any non-ascii
|
||||
clean = clean.replace(/[^\x00-\x7F]+/g, '');
|
||||
clean = clean.trim();
|
||||
output.push(clean);
|
||||
},
|
||||
} as unknown as WriteStream & { fd: any },
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { sep, resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../paths';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import { createMockOutputStream, mockPaths } from './common/testUtils';
|
||||
import { frontendPlugin } from './frontendPlugin';
|
||||
|
||||
const appTsxContent = `
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
|
||||
const router = (
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</FlatRoutes>
|
||||
)
|
||||
`;
|
||||
|
||||
describe('frontendPlugin factory', () => {
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: '/root',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a frontend plugin', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
packages: {
|
||||
app: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'App.tsx': appTsxContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: mockFs.directory(),
|
||||
},
|
||||
[paths.resolveOwn('templates')]: mockFs.load(
|
||||
paths.resolveOwn('templates'),
|
||||
),
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await frontendPlugin.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expect(output).toEqual([
|
||||
'',
|
||||
'Creating backend plugin backstage-plugin-test',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'copying tsconfig.json',
|
||||
'templating index.tsx.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'templating plugin.test.ts.hbs',
|
||||
'templating plugin.ts.hbs',
|
||||
'templating routes.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'templating ExampleComponent.test.tsx.hbs',
|
||||
'templating ExampleComponent.tsx.hbs',
|
||||
'copying index.ts',
|
||||
'templating ExampleFetchComponent.test.tsx.hbs',
|
||||
'templating ExampleFetchComponent.tsx.hbs',
|
||||
'copying index.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test`,
|
||||
'app adding dependency',
|
||||
'app adding import',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson('/root/packages/app/package.json'),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
|
||||
.toBe(`
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
import { TestPage } from 'backstage-plugin-test';
|
||||
|
||||
const router = (
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/test" element={<TestPage />} />
|
||||
</FlatRoutes>
|
||||
)
|
||||
`);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: resolvePath('/root/plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: resolvePath('/root/plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a frontend plugin with more options and codeowners', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
CODEOWNERS: '',
|
||||
packages: {
|
||||
app: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'App.tsx': appTsxContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: mockFs.directory(),
|
||||
},
|
||||
[paths.resolveOwn('templates')]: mockFs.load(
|
||||
paths.resolveOwn('templates'),
|
||||
),
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(frontendPlugin, {
|
||||
id: 'test',
|
||||
owner: '@test-user',
|
||||
});
|
||||
|
||||
const [, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await frontendPlugin.create(options, {
|
||||
scope: 'internal',
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readJson('/root/packages/app/package.json'),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'@internal/plugin-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves
|
||||
.toBe(`
|
||||
import { createApp } from '@backstage/app-defaults';
|
||||
import { TestPage } from '@internal/plugin-test';
|
||||
|
||||
const router = (
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/test" element={<TestPage />} />
|
||||
</FlatRoutes>
|
||||
)
|
||||
`);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: resolvePath('/root/plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: resolvePath('/root/plugins/test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { createFactory, CreateContext } from '../types';
|
||||
import { addPackageDependency, Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const frontendPlugin = createFactory<Options>({
|
||||
name: 'plugin',
|
||||
description: 'A new frontend plugin',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
|
||||
const name = ctx.scope
|
||||
? `@${ctx.scope}/plugin-${id}`
|
||||
: `backstage-plugin-${id}`;
|
||||
const extensionName = `${upperFirst(camelCase(id))}Page`;
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', id)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${id}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-plugin',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
extensionName,
|
||||
pluginVar: `${camelCase(id)}Plugin`,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
},
|
||||
});
|
||||
|
||||
if (await fs.pathExists(paths.resolveTargetRoot('packages/app'))) {
|
||||
await Task.forItem('app', 'adding dependency', async () => {
|
||||
await addPackageDependency(
|
||||
paths.resolveTargetRoot('packages/app/package.json'),
|
||||
{
|
||||
dependencies: {
|
||||
[name]: `^${ctx.defaultVersion}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Task.forItem('app', 'adding import', async () => {
|
||||
const pluginsFilePath = paths.resolveTargetRoot(
|
||||
'packages/app/src/App.tsx',
|
||||
);
|
||||
if (!(await fs.pathExists(pluginsFilePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(pluginsFilePath, 'utf8');
|
||||
const revLines = content.split('\n').reverse();
|
||||
|
||||
const lastImportIndex = revLines.findIndex(line =>
|
||||
line.match(/ from ("|').*("|')/),
|
||||
);
|
||||
const lastRouteIndex = revLines.findIndex(line =>
|
||||
line.match(/<\/FlatRoutes/),
|
||||
);
|
||||
|
||||
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
|
||||
const importLine = `import { ${extensionName} } from '${name}';`;
|
||||
if (!content.includes(importLine)) {
|
||||
revLines.splice(lastImportIndex, 0, importLine);
|
||||
}
|
||||
|
||||
const componentLine = `<Route path="/${id}" element={<${extensionName} />} />`;
|
||||
if (!content.includes(componentLine)) {
|
||||
const [indentation] =
|
||||
revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
|
||||
revLines.splice(lastRouteIndex + 1, 0, indentation + componentLine);
|
||||
}
|
||||
|
||||
const newContent = revLines.reverse().join('\n');
|
||||
await fs.writeFile(pluginsFilePath, newContent, 'utf8');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${id}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 { frontendPlugin } from './frontendPlugin';
|
||||
export { backendPlugin } from './backendPlugin';
|
||||
export { pluginCommon } from './pluginCommon';
|
||||
export { scaffolderModule } from './scaffolderModule';
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { sep, resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../paths';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import { createMockOutputStream, mockPaths } from './common/testUtils';
|
||||
import { pluginCommon } from './pluginCommon';
|
||||
|
||||
describe('pluginCommon factory', () => {
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: '/root',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a common plugin package', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
plugins: mockFs.directory(),
|
||||
},
|
||||
[paths.resolveOwn('templates')]: mockFs.load(
|
||||
paths.resolveOwn('templates'),
|
||||
),
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(pluginCommon, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await pluginCommon.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: () => fs.mkdtemp('test'),
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expect(output).toEqual([
|
||||
'',
|
||||
'Creating backend plugin backstage-plugin-test-common',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}test-common`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'copying tsconfig.json',
|
||||
'templating index.ts.hbs',
|
||||
'copying setupTests.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}test-common`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson('/root/plugins/test-common/package.json'),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'backstage-plugin-test-common',
|
||||
description: 'Common functionalities for the test plugin',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: resolvePath('/root/plugins/test-common'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: resolvePath('/root/plugins/test-common'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { createFactory, CreateContext } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt, pluginIdPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const pluginCommon = createFactory<Options>({
|
||||
name: 'plugin-common',
|
||||
description: 'A new isomorphic common plugin package',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [pluginIdPrompt(), ownerPrompt()],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const suffix = `${id}-common`;
|
||||
const name = ctx.scope
|
||||
? `@${ctx.scope}/plugin-${suffix}`
|
||||
: `backstage-plugin-${suffix}`;
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating backend plugin ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', suffix)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${suffix}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'default-common-plugin-package',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${suffix}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { sep, resolve as resolvePath } from 'path';
|
||||
import { paths } from '../../paths';
|
||||
import { Task } from '../../tasks';
|
||||
import { FactoryRegistry } from '../FactoryRegistry';
|
||||
import { createMockOutputStream, mockPaths } from './common/testUtils';
|
||||
import { scaffolderModule } from './scaffolderModule';
|
||||
|
||||
describe('scaffolderModule factory', () => {
|
||||
beforeEach(() => {
|
||||
mockPaths({
|
||||
targetRoot: '/root',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should create a scaffolder backend module package', async () => {
|
||||
mockFs({
|
||||
'/root': {
|
||||
plugins: mockFs.directory(),
|
||||
},
|
||||
[paths.resolveOwn('templates')]: mockFs.load(
|
||||
paths.resolveOwn('templates'),
|
||||
),
|
||||
});
|
||||
|
||||
const options = await FactoryRegistry.populateOptions(scaffolderModule, {
|
||||
id: 'test',
|
||||
});
|
||||
|
||||
let modified = false;
|
||||
|
||||
const [output, mockStream] = createMockOutputStream();
|
||||
jest.spyOn(process, 'stderr', 'get').mockReturnValue(mockStream);
|
||||
jest.spyOn(Task, 'forCommand').mockResolvedValue();
|
||||
|
||||
await scaffolderModule.create(options, {
|
||||
private: true,
|
||||
isMonoRepo: true,
|
||||
defaultVersion: '1.0.0',
|
||||
markAsModified: () => {
|
||||
modified = true;
|
||||
},
|
||||
createTemporaryDirectory: (name: string) => fs.mkdtemp(name),
|
||||
});
|
||||
|
||||
expect(modified).toBe(true);
|
||||
|
||||
expect(output).toEqual([
|
||||
'',
|
||||
'Creating module backstage-plugin-scaffolder-backend-module-test',
|
||||
'Checking Prerequisites:',
|
||||
`availability plugins${sep}scaffolder-backend-module-test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'copying tsconfig.json',
|
||||
'templating index.ts.hbs',
|
||||
'copying index.ts',
|
||||
'copying example.test.ts',
|
||||
'copying example.ts',
|
||||
'copying index.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}scaffolder-backend-module-test`,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'backstage-plugin-scaffolder-backend-module-test',
|
||||
description: 'The test module for @backstage/plugin-scaffolder-backend',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(Task.forCommand).toHaveBeenCalledTimes(2);
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn install', {
|
||||
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
|
||||
optional: true,
|
||||
});
|
||||
expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', {
|
||||
cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'),
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { createFactory, CreateContext } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { ownerPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
|
||||
type Options = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
codeOwnersPath?: string;
|
||||
};
|
||||
|
||||
export const scaffolderModule = createFactory<Options>({
|
||||
name: 'scaffolder-module',
|
||||
description:
|
||||
'An module exporting custom actions for @backstage/plugin-scaffolder-backend',
|
||||
optionsDiscovery: async () => ({
|
||||
codeOwnersPath: await getCodeownersFilePath(paths.targetRoot),
|
||||
}),
|
||||
optionsPrompts: [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'id',
|
||||
message: 'Enter the name of the module [required]',
|
||||
validate: (value: string) => {
|
||||
if (!value) {
|
||||
return 'Please enter the name of the module';
|
||||
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
||||
return 'Module names must be lowercase and contain only letters, digits, and dashes.';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
ownerPrompt(),
|
||||
],
|
||||
async create(options: Options, ctx: CreateContext) {
|
||||
const { id } = options;
|
||||
const slug = `scaffolder-backend-module-${id}`;
|
||||
|
||||
let name = `backstage-plugin-${slug}`;
|
||||
if (ctx.scope) {
|
||||
if (ctx.scope === 'backstage') {
|
||||
name = `@backstage/plugin-${slug}`;
|
||||
} else {
|
||||
name = `@${ctx.scope}/backstage-plugin-${slug}`;
|
||||
}
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(`Creating module ${chalk.cyan(name)}`);
|
||||
|
||||
const targetDir = ctx.isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', slug)
|
||||
: paths.resolveTargetRoot(`backstage-plugin-${slug}`);
|
||||
|
||||
await executePluginPackageTemplate(ctx, {
|
||||
targetDir,
|
||||
templateName: 'scaffolder-module',
|
||||
values: {
|
||||
id,
|
||||
name,
|
||||
privatePackage: ctx.private,
|
||||
npmRegistry: ctx.npmRegistry,
|
||||
pluginVersion: ctx.defaultVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${slug}`, options.owner);
|
||||
}
|
||||
|
||||
await Task.forCommand('yarn install', { cwd: targetDir, optional: true });
|
||||
await Task.forCommand('yarn lint --fix', {
|
||||
cwd: targetDir,
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { DistinctQuestion } from 'inquirer';
|
||||
|
||||
export interface CreateContext {
|
||||
/** The package scope to use for new packages */
|
||||
scope?: string;
|
||||
/** The NPM registry to use for new packages */
|
||||
npmRegistry?: string;
|
||||
/** Whether new packages should be marked as private */
|
||||
private: boolean;
|
||||
/** Whether we are creating something in a monorepo or not */
|
||||
isMonoRepo: boolean;
|
||||
/** The default version to use for new packages */
|
||||
defaultVersion: string;
|
||||
|
||||
/** Creates a temporary directory. This will always be deleted after creation is done. */
|
||||
createTemporaryDirectory(name: string): Promise<string>;
|
||||
|
||||
/** Signal that the creation process got to a point where permanent modifications were made */
|
||||
markAsModified(): void;
|
||||
}
|
||||
|
||||
export type AnyOptions = Record<string, string>;
|
||||
|
||||
export type Prompt<TOptions> = DistinctQuestion<TOptions> & { name: string };
|
||||
|
||||
export interface Factory<TOptions extends AnyOptions> {
|
||||
/**
|
||||
* The name used for this factory.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* A description that describes what this factory creates to the user.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* An optional options discovery step that is run
|
||||
* before the prompts to potentially fill in some of the options.
|
||||
*/
|
||||
optionsDiscovery?(): Promise<Partial<TOptions>>;
|
||||
|
||||
/**
|
||||
* Inquirer prompts that will be filled in either interactively or
|
||||
* through command line arguments.
|
||||
*/
|
||||
optionsPrompts?: ReadonlyArray<Prompt<TOptions>>;
|
||||
|
||||
/**
|
||||
* The main method of the factory that handles creation.
|
||||
*/
|
||||
create(options: TOptions, context?: CreateContext): Promise<void>;
|
||||
}
|
||||
|
||||
export type AnyFactory = Factory<AnyOptions>;
|
||||
|
||||
export function createFactory<TOptions extends AnyOptions>(
|
||||
config: Factory<TOptions>,
|
||||
): AnyFactory {
|
||||
return config as AnyFactory;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -18,35 +18,40 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import handlebars from 'handlebars';
|
||||
import ora from 'ora';
|
||||
import { promisify } from 'util';
|
||||
import { basename, dirname } from 'path';
|
||||
import recursive from 'recursive-readdir';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { paths } from './paths';
|
||||
import { assertError } from '@backstage/errors';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
const TASK_NAME_MAX_LENGTH = 14;
|
||||
|
||||
export class Task {
|
||||
static log(name: string = '') {
|
||||
process.stdout.write(`${chalk.green(name)}\n`);
|
||||
process.stderr.write(`${chalk.green(name)}\n`);
|
||||
}
|
||||
|
||||
static error(message: string = '') {
|
||||
process.stdout.write(`\n${chalk.red(message)}\n\n`);
|
||||
process.stderr.write(`\n${chalk.red(message)}\n\n`);
|
||||
}
|
||||
|
||||
static section(name: string) {
|
||||
const title = chalk.green(`${name}:`);
|
||||
process.stdout.write(`\n ${title}\n`);
|
||||
process.stderr.write(`\n ${title}\n`);
|
||||
}
|
||||
|
||||
static exit(code: number = 0) {
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
static async forItem(
|
||||
static async forItem<T = void>(
|
||||
task: string,
|
||||
item: string,
|
||||
taskFunc: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
taskFunc: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH));
|
||||
|
||||
const spinner = ora({
|
||||
@@ -56,13 +61,40 @@ export class Task {
|
||||
}).start();
|
||||
|
||||
try {
|
||||
await taskFunc();
|
||||
const result = await taskFunc();
|
||||
spinner.succeed();
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner.fail();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async forCommand(
|
||||
command: string,
|
||||
options?: { cwd?: string; optional?: boolean },
|
||||
) {
|
||||
try {
|
||||
await Task.forItem('executing', command, async () => {
|
||||
await exec(command, { cwd: options?.cwd });
|
||||
});
|
||||
} catch (error) {
|
||||
assertError(error);
|
||||
if (error.stderr) {
|
||||
process.stderr.write(error.stderr as Buffer);
|
||||
}
|
||||
if (error.stdout) {
|
||||
process.stdout.write(error.stdout as Buffer);
|
||||
}
|
||||
if (options?.optional) {
|
||||
Task.error(`Warning: Failed to execute command ${chalk.cyan(command)}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to execute command '${chalk.cyan(command)}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function templatingTask(
|
||||
@@ -85,7 +117,9 @@ export async function templatingTask(
|
||||
const destination = destinationFile.replace(/\.hbs$/, '');
|
||||
|
||||
const template = await fs.readFile(file);
|
||||
const compiled = handlebars.compile(template.toString());
|
||||
const compiled = handlebars.compile(template.toString(), {
|
||||
strict: true,
|
||||
});
|
||||
const contents = compiled(
|
||||
{ name: basename(destination), ...context },
|
||||
{
|
||||
@@ -122,3 +156,44 @@ export async function templatingTask(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPackageDependency(
|
||||
path: string,
|
||||
options: {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const pkgJson = await fs.readJson(path);
|
||||
|
||||
const normalize = (obj: Record<string, string>) => {
|
||||
if (Object.keys(obj).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.keys(obj)
|
||||
.sort()
|
||||
.map(key => [key, obj[key]]),
|
||||
);
|
||||
};
|
||||
|
||||
pkgJson.dependencies = normalize({
|
||||
...pkgJson.dependencies,
|
||||
...options.dependencies,
|
||||
});
|
||||
pkgJson.devDependencies = normalize({
|
||||
...pkgJson.devDependencies,
|
||||
...options.devDependencies,
|
||||
});
|
||||
pkgJson.peerDependencies = normalize({
|
||||
...pkgJson.peerDependencies,
|
||||
...options.peerDependencies,
|
||||
});
|
||||
|
||||
await fs.writeJson(path, pkgJson, { spaces: 2 });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to add package dependencies, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { version as corePluginApi } from '@backstage/core-plugin-api/package.jso
|
||||
import { version as devUtils } from '@backstage/dev-utils/package.json';
|
||||
import { version as testUtils } from '@backstage/test-utils/package.json';
|
||||
import { version as theme } from '@backstage/theme/package.json';
|
||||
import { version as scaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json';
|
||||
|
||||
export const packageVersions: Record<string, string> = {
|
||||
'@backstage/backend-common': backendCommon,
|
||||
@@ -53,6 +54,7 @@ export const packageVersions: Record<string, string> = {
|
||||
'@backstage/dev-utils': devUtils,
|
||||
'@backstage/test-utils': testUtils,
|
||||
'@backstage/theme': theme,
|
||||
'@backstage/plugin-scaffolder-backend': scaffolderBackend,
|
||||
};
|
||||
|
||||
export function findVersion() {
|
||||
|
||||
@@ -4,41 +4,43 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
{{#if privatePackage}} "private": {{privatePackage}},
|
||||
{{/if}}
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
|
||||
"@backstage/config": "{{versionQuery '@backstage/config'}}",
|
||||
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
|
||||
"express": "{{versionQuery 'express' '4.17.1'}}",
|
||||
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
|
||||
"winston": "{{versionQuery 'winston' '3.2.1'}}",
|
||||
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
|
||||
"yn": "{{versionQuery 'yn' '4.0.0'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
|
||||
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
|
||||
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
|
||||
"msw": "{{versionQuery 'msw' '0.35.0'}}"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
|
||||
"@backstage/config": "{{versionQuery '@backstage/config'}}",
|
||||
"@types/express": "{{versionQuery '@types/express' '4.17.6'}}",
|
||||
"express": "{{versionQuery 'express' '4.17.1'}}",
|
||||
"express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}",
|
||||
"winston": "{{versionQuery 'winston' '3.2.1'}}",
|
||||
"cross-fetch": "{{versionQuery 'cross-fetch' '3.0.6'}}",
|
||||
"yn": "{{versionQuery 'yn' '4.0.0'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
|
||||
"@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}",
|
||||
"supertest": "{{versionQuery 'supertest' '4.0.2'}}",
|
||||
"msw": "{{versionQuery 'msw' '0.35.0'}}"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
# {{name}}
|
||||
|
||||
Welcome to the common package for the {{id}} plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"description": "Common functionalities for the {{id}} plugin",
|
||||
"version": "{{pluginVersion}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/***/
|
||||
/**
|
||||
* Common functionalities for the {{id}} plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* In this package you might for example declare types that are common
|
||||
* between the frontend and backend plugin packages.
|
||||
*/
|
||||
export type CommonType = {
|
||||
field: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Or you might declare some common constants.
|
||||
*/
|
||||
export const COMMON_CONSTANT = 1
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
{{#if privatePackage}} "private": {{privatePackage}},
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
# {{name}}
|
||||
|
||||
The {{id}} module for [@backstage/plugin-scaffolder-backend](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend).
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"description": "The {{id}} module for @backstage/plugin-scaffolder-backend",
|
||||
"version": "{{pluginVersion}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
{{#if privatePackage}}
|
||||
"private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}}
|
||||
"registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli build --output cjs,types",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/plugin-scaffolder-backend": "{{versionQuery '@backstage/plugin-scaffolder-backend'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -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 { PassThrough } from 'stream';
|
||||
import { createAcmeExampleAction } from './example';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
describe('acme:example', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call action', async () => {
|
||||
const action = createAcmeExampleAction();
|
||||
|
||||
const logger = getVoidLogger();
|
||||
jest.spyOn(logger, 'info');
|
||||
|
||||
await action.handler({
|
||||
input: {
|
||||
myParameter: 'test',
|
||||
},
|
||||
workspacePath: '/tmp',
|
||||
logger,
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory() {
|
||||
// Usage of mock-fs is recommended for testing of filesystem operations
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Running example template with parameters: test',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
|
||||
|
||||
/**
|
||||
* Creates an `acme:example` Scaffolder action.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://example.com} for more information.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createAcmeExampleAction() {
|
||||
// For more information on how to define custom actions, see
|
||||
// https://backstage.io/docs/features/software-templates/writing-custom-actions
|
||||
return createTemplateAction<{
|
||||
myParameter: string;
|
||||
}>({
|
||||
id: 'acme:example',
|
||||
description: 'Runs Yeoman on an installed Yeoman generator',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['myParameter'],
|
||||
properties: {
|
||||
myParameter: {
|
||||
title: 'An example parameter',
|
||||
description: 'This is the schema for our example parameter',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
ctx.logger.info(
|
||||
`Running example template with parameters: ${ctx.input.myParameter}`,
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createAcmeExampleAction } from './example';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './example';
|
||||
@@ -0,0 +1,8 @@
|
||||
/***/
|
||||
/**
|
||||
* The {{id}} module for @backstage/plugin-scaffolder-backend.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './actions';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-types",
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
# @backstage/codemods
|
||||
|
||||
## 0.1.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/core-components@0.7.3
|
||||
- @backstage/core-plugin-api@0.1.13
|
||||
- @backstage/core-app-api@0.1.20
|
||||
|
||||
## 0.1.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/codemods",
|
||||
"description": "A collection of codemods for Backstage projects",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.21",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user