Merge branch 'master' into migrate/oss

This commit is contained in:
Mert Can Bilgiç
2021-11-16 10:27:39 +03:00
399 changed files with 8547 additions and 2210 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+17
View File
@@ -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)
+26
View File
@@ -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>;
};
```
+53
View File
@@ -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();
});
});
+93
View File
@@ -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>;
};
+274
View File
@@ -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,
};
@@ -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>
),
},
];
+24
View File
@@ -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';
+17
View File
@@ -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';
+1
View File
@@ -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",
+2 -2
View File
@@ -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 (
+2 -2
View File
@@ -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" />
+9
View File
@@ -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
+3
View File
@@ -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;
+4 -4
View File
@@ -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);
});
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+35
View File
@@ -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)
+45
View File
@@ -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');
};
+55
View File
@@ -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;
};
+23
View File
@@ -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';
+17
View File
@@ -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;
}
}
+22
View File
@@ -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';
+138
View File
@@ -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);
});
});
});
+107
View File
@@ -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;
}
+47
View File
@@ -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: {}
+47
View File
@@ -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: {}
+15
View File
@@ -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
+11 -11
View File
@@ -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"
+1 -1
View File
@@ -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) {
+6
View File
@@ -1,5 +1,11 @@
# @backstage/catalog-client
## 0.5.1
### Patch Changes
- 39e92897e4: Improved API documentation for catalog-client.
## 0.5.0
### Minor Changes
+3 -3
View File
@@ -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"
},
+1 -1
View File
@@ -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"
+13
View File
@@ -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
View File
@@ -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();
+135
View File
@@ -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}`,
);
}
}
}
};
+25
View File
@@ -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);
+26 -1
View File
@@ -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
}),
);
}
+24 -8
View File
@@ -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;
}
+4 -1
View File
@@ -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,
});
},
});
+77
View File
@@ -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;
}
+11 -10
View File
@@ -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);
+83 -8
View File
@@ -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}`);
}
}
+2
View File
@@ -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": "."
}
}
+9
View File
@@ -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 -1
View File
@@ -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