Merge remote-tracking branch 'origin/master' into blam/break-up-scaffolder

* origin/master: (55 commits)
  backend-app-api: switch plugin log label pluginId->plugin
  backend-app-api: create a labelled logger for rootHttpRouter
  backend-app-api: remove startHttpServer
  fix(deps): update dependency @rjsf/utils to v5.0.0-beta.16
  backend-app-api: fix infinite loop in http server stop
  remove autoformatting changes
  remove dev file
  use index for key in react fragment
  Minor grammar fix to CONTRIBUTING.md
  backend-app-api: explicit request handler types for MiddlewareFactory
  added changesets for the root API refactor and move from backend-common
  backend-app-api: update API report + fixes
  add highlighting of legend item
  docs(docs/features/software-templates/writing-custom-field-extensions.md): change "kehab" to "kebab".
  backend-app-api: move http dir out of lib
  backend-common: reimplement service builder using backend-app-api
  backend-app-api: move cors and helmet options reader to http folder
  address review comments
  fix picky cli-report
  fix logic
  ...

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2023-01-10 14:19:53 +01:00
89 changed files with 2360 additions and 1013 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-defaults': patch
'@backstage/backend-app-api': patch
---
Use new `ServiceFactoryOrFunction` type.
+5
View File
@@ -0,0 +1,5 @@
---
'@techdocs/cli': minor
---
Add `--preview-app-bundle-path` and `--preview-app-port` options to the `serve` command enabling previewing with apps other than the provided one
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added `ServiceFactoryOrFunction` type, for use when either a `ServiceFactory` or `() => ServiceFactory` can be used.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Moved over implementation of the root HTTP service from `@backstage/backend-common`, and replaced the `middleware` option with a `configure` callback option.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-tasks': patch
---
Minor internal refactor to avoid import cycle issue.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-tech-radar': minor
---
Add highlighting of legend item and show bubble on hover within the Tech Radar
+69
View File
@@ -0,0 +1,69 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
This patch adds changes to provide examples alongside scaffolder task actions.
The `createTemplateAction` function now takes a list of examples e.g.
```typescript
const actionExamples = [
{
description: 'Example 1',
example: yaml.stringify({
steps: [
{
action: 'test:action',
id: 'test',
input: {
input1: 'value',
},
},
],
}),
},
];
export function createTestAction() {
return createTemplateAction({
id: 'test:action',
examples: [
{
description: 'Example 1',
examples: actionExamples
}
],
...,
});
```
These examples can be retrieved later from the api.
```bash
curl http://localhost:7007/api/scaffolder/v2/actions
```
```json
[
{
"id": "test:action",
"examples": [
{
"description": "Example 1",
"example": "steps:\n - action: test:action\n id: test\n input:\n input1: value\n"
}
],
"schema": {
"input": {
"type": "object",
"properties": {
"input1": {
"title": "Input 1",
"type": "string"
}
}
}
}
}
]
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Refactor to rely on `@backstage/backend-app-api` for the implementation of `createServiceBuilder`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Tweaked the plugin logger to use `plugin` as the label for the plugin ID, rather than `pluginId`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Show action example yaml on the scaffolder actions documentation page.
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch
'@backstage/plugin-catalog-backend-module-bitbucket-server': patch
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch
'@backstage/plugin-catalog-backend-module-msgraph': patch
'@backstage/plugin-catalog-backend-module-gerrit': patch
'@backstage/plugin-catalog-backend-module-github': patch
'@backstage/plugin-catalog-backend-module-gitlab': patch
'@backstage/plugin-events-backend-module-aws-sqs': patch
'@backstage/plugin-catalog-backend-module-azure': patch
'@backstage/plugin-catalog-backend-module-ldap': patch
'@backstage/plugin-catalog-backend-module-aws': patch
'@techdocs/cli': patch
---
Provide context for logged errors.
+1 -1
View File
@@ -142,7 +142,7 @@ Changesets **are** needed for new packages, as that is what triggers the package
6. Push the commit with your changeset to the branch associated with your PR
7. Accept our gratitude for making the release process easier on the maintainers
For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository.
For more information, check out [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository.
## Merging to Master
@@ -23,18 +23,19 @@ the `Scaffolder` frontend plugin in your own `App.tsx`.
You can create your own Field Extension by using the
[`createScaffolderFieldExtension`](https://backstage.io/docs/reference/plugin-scaffolder.createscaffolderfieldextension)
`API` like below:
`API` like below.
As an example, we will create a component that validates whether a string is in the `Kebab-case` pattern:
```tsx
//packages/app/src/scaffolder/MyCustomExtension/MyCustomExtension.tsx
//packages/app/src/scaffolder/ValidateKebabCase/ValidateKebabCaseExtension.tsx
import React from 'react';
import { FieldProps, FieldValidation } from '@rjsf/core';
import FormControl from '@material-ui/core/FormControl';
import { KubernetesValidatorFunctions } from '@backstage/catalog-model';
/*
This is the actual component that will get rendered in the form
*/
export const MyCustomExtension = ({
export const ValidateKebabCaseExtension = ({
onChange,
rawErrors,
required,
@@ -45,8 +46,17 @@ export const MyCustomExtension = ({
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
onChange={onChange}
/>
>
<InputLabel htmlFor="validateName">Name</InputLabel>
<Input
id="validateName"
aria-describedby="entityName"
onChange={e => onChange(e.target?.value)}
/>
<FormHelperText id="entityName">
Use only letters, numbers, hyphens and underscores
</FormHelperText>
</FormControl>
);
};
@@ -55,20 +65,22 @@ export const MyCustomExtension = ({
You will get the value from the `onChange` handler before as the value here to make sure that the types are aligned\
*/
export const myCustomValidation = (
export const validateKebabCaseValidation = (
value: string,
validation: FieldValidation,
) => {
if (!KubernetesValidatorFunctions.isValidObjectName(value)) {
const kebabCase = /^[a-z0-9-_]+$/g.test(value);
if (kebabCase === false) {
validation.addError(
'must start and end with an alphanumeric character, and contain only alphanumeric characters, hyphens, underscores, and periods. Maximum length is 63 characters.',
`Only use letters, numbers, hyphen ("-") and underscore ("_").`,
);
}
};
```
```tsx
// packages/app/src/scaffolder/MyCustomExtension/extensions.ts
// packages/app/src/scaffolder/ValidateKebabCase/extensions.ts
/*
This is where the magic happens and creates the custom field extension.
@@ -81,21 +93,24 @@ import {
scaffolderPlugin,
createScaffolderFieldExtension,
} from '@backstage/plugin-scaffolder';
import { MyCustomExtension, myCustomValidation } from './MyCustomExtension';
import {
ValidateKebabCase,
validateKebabCaseValidation,
} from './ValidateKebabCase';
export const MyCustomFieldExtension = scaffolderPlugin.provide(
export const ValidateKebabCaseFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
name: 'MyCustomExtension',
component: MyCustomExtension,
validation: myCustomValidation,
name: 'ValidateKebabCase',
component: ValidateKebabCase,
validation: validateKebabCaseValidation,
}),
);
```
```tsx
// packages/app/src/scaffolder/MyCustomExtension/index.ts
// packages/app/src/scaffolder/ValidateKebabCase/index.ts
export { MyCustomFieldExtension } from './extensions';
export { ValidateKebabCaseFieldExtension } from './extensions';
```
Once all these files are in place, you then need to provide your custom
@@ -117,7 +132,7 @@ const routes = (
Should look something like this instead:
```tsx
import { MyCustomFieldExtension } from './scaffolder/MyCustomExtension';
import { ValidateKebabCaseFieldExtension } from './scaffolder/ValidateKebabCase';
import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder';
const routes = (
@@ -125,7 +140,7 @@ const routes = (
...
<Route path="/create" element={<ScaffolderPage />}>
<ScaffolderFieldExtensions>
<MyCustomFieldExtension />
<ValidateKebabCaseFieldExtension />
</ScaffolderFieldExtensions>
</Route>
...
@@ -158,7 +173,9 @@ spec:
title: Name
type: string
description: My custom name for the component
ui:field: MyCustomExtension
ui:field: ValidateKebabCaseExtension
steps:
[...]
```
## Access Data from other Fields
+16 -8
View File
@@ -70,6 +70,11 @@ a Backstage app server on port 3000. The Backstage app has a custom TechDocs API
implementation, which uses the MkDocs preview server as a proxy to fetch the
generated documentation files and assets.
Backstage instances might differ from the provided preview app in appearance and
behavior. To preview documentation with a different app, use
`--preview-app-bundle-path` with a path to the bundle of the app to use instead.
Typically, a `dist` or `build` directory.
NOTE: When using a custom `techdocs` docker image, make sure the entry point is
also `ENTRYPOINT ["mkdocs"]` or override with `--docker-entrypoint`.
@@ -81,14 +86,17 @@ Usage: techdocs-cli serve [options]
Serve a documentation project locally in a Backstage app-like environment
Options:
-i, --docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs")
--docker-entrypoint <DOCKER_ENTRYPOINT> Override the image entrypoint
--docker-option <DOCKER_OPTION...> Extra options to pass to the docker run command, e.g. "--add-host=internal.host:192.168.11.12"
(can be added multiple times).
--no-docker Do not use Docker, use MkDocs executable in current user environment.
--mkdocs-port <PORT> Port for MkDocs server to use (default: "8000")
-v --verbose Enable verbose output. (default: false)
-h, --help display help for command
-i, --docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs")
--docker-entrypoint <DOCKER_ENTRYPOINT> Override the image entrypoint
--docker-option <DOCKER_OPTION...> Extra options to pass to the docker run command, e.g. "--add-host=internal.host:192.168.11.12"
(can be added multiple times).
--no-docker Do not use Docker, use MkDocs executable in current user environment.
--mkdocs-port <PORT> Port for MkDocs server to use (default: "8000")
--preview-app-bundle-path <PATH_TO_BUNDLE> Preview documentation using a web app other than the included one.
--preview-app-port <PORT> Port where the preview will be served.
Can only be used with "--preview-app-bundle-path". (default: "3000")
-v --verbose Enable verbose output. (default: false)
-h, --help display help for command
```
### Generate TechDocs site from a documentation project
+10
View File
@@ -0,0 +1,10 @@
---
title: Toolbox
author: drodil
authorUrl: https://github.com/drodil
category: Tools
description: Development related tools within Backstage
documentation: https://github.com/drodil/backstage-plugin-toolbox
iconUrl: img/toolbox-logo.png
npmPackageName: '@drodil/backstage-plugin-toolbox'
addedDate: '2023-01-09'
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+105 -3
View File
@@ -3,10 +3,17 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { ConfigService } from '@backstage/backend-plugin-api';
import { CorsOptions } from 'cors';
import { ErrorRequestHandler } from 'express';
import { Express as Express_2 } from 'express';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { Handler } from 'express';
import { HelmetOptions } from 'helmet';
import * as http from 'http';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
@@ -14,11 +21,14 @@ import { PermissionsService } from '@backstage/backend-plugin-api';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { RequestHandler } from 'express';
import { RequestListener } from 'http';
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { RootLoggerService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
import { TokenManagerService } from '@backstage/backend-plugin-api';
import { UrlReader } from '@backstage/backend-common';
@@ -43,6 +53,15 @@ export const configFactory: (
options?: undefined,
) => ServiceFactory<ConfigService>;
// @public
export function createHttpServer(
listener: RequestListener,
options: HttpServerOptions,
deps: {
logger: LoggerService;
},
): Promise<ExtendedHttpServer>;
// @public (undocumented)
export function createSpecializedBackend(
options: CreateSpecializedBackendOptions,
@@ -51,7 +70,7 @@ export function createSpecializedBackend(
// @public (undocumented)
export interface CreateSpecializedBackendOptions {
// (undocumented)
services: (ServiceFactory | (() => ServiceFactory))[];
services: ServiceFactoryOrFunction[];
}
// @public (undocumented)
@@ -64,6 +83,16 @@ export const discoveryFactory: (
options?: undefined,
) => ServiceFactory<PluginEndpointDiscovery>;
// @public
export interface ExtendedHttpServer extends http.Server {
// (undocumented)
port(): number;
// (undocumented)
start(): Promise<void>;
// (undocumented)
stop(): Promise<void>;
}
// @public (undocumented)
export const httpRouterFactory: (
options?: HttpRouterFactoryOptions | undefined,
@@ -74,6 +103,29 @@ export type HttpRouterFactoryOptions = {
getPath(pluginId: string): string;
};
// @public
export type HttpServerCertificateOptions =
| {
type: 'plain';
key: string;
cert: string;
}
| {
type: 'generated';
hostname: string;
};
// @public
export type HttpServerOptions = {
listen: {
port: number;
host: string;
};
https?: {
certificate: HttpServerCertificateOptions;
};
};
// @public
export const lifecycleFactory: (
options?: undefined,
@@ -84,11 +136,61 @@ export const loggerFactory: (
options?: undefined,
) => ServiceFactory<LoggerService>;
// @public
export class MiddlewareFactory {
compression(): RequestHandler;
cors(): RequestHandler;
static create(options: MiddlewareFactoryOptions): MiddlewareFactory;
error(options?: MiddlewareFactoryErrorOptions): ErrorRequestHandler;
helmet(): RequestHandler;
logging(): RequestHandler;
notFound(): RequestHandler;
}
// @public
export interface MiddlewareFactoryErrorOptions {
logAllErrors?: boolean;
showStackTraces?: boolean;
}
// @public
export interface MiddlewareFactoryOptions {
// (undocumented)
config: ConfigService;
// (undocumented)
logger: LoggerService;
}
// @public (undocumented)
export const permissionsFactory: (
options?: undefined,
) => ServiceFactory<PermissionsService>;
// @public
export function readCorsOptions(config?: Config): CorsOptions;
// @public
export function readHelmetOptions(config?: Config): HelmetOptions;
// @public
export function readHttpServerOptions(config?: Config): HttpServerOptions;
// @public (undocumented)
export interface RootHttpRouterConfigureOptions {
// (undocumented)
app: Express_2;
// (undocumented)
config: ConfigService;
// (undocumented)
lifecycle: LifecycleService;
// (undocumented)
logger: LoggerService;
// (undocumented)
middleware: MiddlewareFactory;
// (undocumented)
routes: RequestHandler;
}
// @public (undocumented)
export const rootHttpRouterFactory: (
options?: RootHttpRouterFactoryOptions | undefined,
@@ -97,7 +199,7 @@ export const rootHttpRouterFactory: (
// @public (undocumented)
export type RootHttpRouterFactoryOptions = {
indexPath?: string | false;
middleware?: Handler[];
configure?(options: RootHttpRouterConfigureOptions): void;
};
// @public
+20 -1
View File
@@ -36,15 +36,34 @@
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "10.1.0",
"helmet": "^6.0.0",
"minimatch": "^5.0.0",
"morgan": "^1.10.0",
"node-forge": "^1.3.1",
"selfsigned": "^2.0.0",
"stoppable": "^1.1.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
"@backstage/cli": "workspace:^",
"@types/compression": "^1.7.0",
"@types/fs-extra": "^9.0.3",
"@types/http-errors": "^2.0.0",
"@types/morgan": "^1.9.0",
"@types/node-forge": "^1.3.0",
"@types/stoppable": "^1.1.0",
"http-errors": "^2.0.0",
"supertest": "^6.1.3"
},
"files": [
"dist",
@@ -0,0 +1,213 @@
/*
* 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 {
AuthenticationError,
ConflictError,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import express from 'express';
import createError from 'http-errors';
import request from 'supertest';
import { MiddlewareFactory } from './MiddlewareFactory';
import { ConfigReader } from '@backstage/config';
describe('MiddlewareFactory', () => {
describe('middleware.error', () => {
const childLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
};
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: () => childLogger,
};
const middleware = MiddlewareFactory.create({
logger,
config: new ConfigReader({}),
});
beforeEach(() => {
jest.resetAllMocks();
});
it('gives default code and message', async () => {
const app = express();
app.use('/breaks', () => {
throw new Error('some message');
});
app.use(middleware.error());
const response = await request(app).get('/breaks');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: expect.objectContaining({
name: 'Error',
message: 'some message',
}),
request: { method: 'GET', url: '/breaks' },
response: { statusCode: 500 },
});
});
it('does not try to send the response again if its already been sent', async () => {
const app = express();
const mockSend = jest.fn();
app.use('/works_with_async_fail', (_, res) => {
res.status(200).send('hello');
// mutate the response object to test the middleware.
// it's hard to catch errors inside middleware from the outside.
res.send = mockSend;
throw new Error('some message');
});
app.use(middleware.error());
const response = await request(app).get('/works_with_async_fail');
expect(response.status).toBe(200);
expect(response.text).toBe('hello');
expect(mockSend).not.toHaveBeenCalled();
});
it('takes code from http-errors library errors', async () => {
const app = express();
app.use('/breaks', () => {
throw createError(432, 'Some Message');
});
app.use(middleware.error());
const response = await request(app).get('/breaks');
expect(response.status).toBe(432);
expect(response.body).toEqual({
error: {
expose: true,
name: 'BadRequestError',
message: 'Some Message',
status: 432,
statusCode: 432,
},
request: {
method: 'GET',
url: '/breaks',
},
response: { statusCode: 432 },
});
});
it('handles well-known error classes', async () => {
const app = express();
app.use('/NotModifiedError', () => {
throw new NotModifiedError();
});
app.use('/InputError', () => {
throw new InputError();
});
app.use('/AuthenticationError', () => {
throw new AuthenticationError();
});
app.use('/NotAllowedError', () => {
throw new NotAllowedError();
});
app.use('/NotFoundError', () => {
throw new NotFoundError();
});
app.use('/ConflictError', () => {
throw new ConflictError();
});
app.use(middleware.error());
const r = request(app);
expect((await r.get('/NotModifiedError')).status).toBe(304);
expect((await r.get('/InputError')).status).toBe(400);
expect((await r.get('/InputError')).body.error.name).toBe('InputError');
expect((await r.get('/AuthenticationError')).status).toBe(401);
expect((await r.get('/AuthenticationError')).body.error.name).toBe(
'AuthenticationError',
);
expect((await r.get('/NotAllowedError')).status).toBe(403);
expect((await r.get('/NotAllowedError')).body.error.name).toBe(
'NotAllowedError',
);
expect((await r.get('/NotFoundError')).status).toBe(404);
expect((await r.get('/NotFoundError')).body.error.name).toBe(
'NotFoundError',
);
expect((await r.get('/ConflictError')).status).toBe(409);
expect((await r.get('/ConflictError')).body.error.name).toBe(
'ConflictError',
);
});
it('logs all 500 errors', async () => {
const app = express();
const thrownError = new Error('some error');
app.use('/breaks', () => {
throw thrownError;
});
app.use(middleware.error());
await request(app).get('/breaks');
expect(childLogger.error).toHaveBeenCalledWith(
'Request failed with status 500',
thrownError,
);
});
it('does not log 400 errors', async () => {
const app = express();
app.use('/NotFound', () => {
throw new NotFoundError();
});
app.use(middleware.error());
await request(app).get('/NotFound');
expect(childLogger.error).not.toHaveBeenCalled();
});
it('log 400 errors when logAllErrors is true', async () => {
const app = express();
app.use('/NotFound', () => {
throw new NotFoundError();
});
app.use(middleware.error({ logAllErrors: true }));
await request(app).get('/NotFound');
expect(childLogger.error).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,266 @@
/*
* Copyright 2023 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 { ConfigService, LoggerService } from '@backstage/backend-plugin-api';
import {
Request,
Response,
ErrorRequestHandler,
NextFunction,
RequestHandler,
} from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import compression from 'compression';
import { readHelmetOptions } from './readHelmetOptions';
import { readCorsOptions } from './readCorsOptions';
import {
AuthenticationError,
ConflictError,
ErrorResponseBody,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
serializeError,
} from '@backstage/errors';
/**
* Options used to create a {@link MiddlewareFactory}.
*
* @public
*/
export interface MiddlewareFactoryOptions {
config: ConfigService;
logger: LoggerService;
}
/**
* Options passed to the {@link MiddlewareFactory.error} middleware.
*
* @public
*/
export interface MiddlewareFactoryErrorOptions {
/**
* Whether error response bodies should show error stack traces or not.
*
* If not specified, by default shows stack traces only in development mode.
*/
showStackTraces?: boolean;
/**
* Whether any 4xx errors should be logged or not.
*
* If not specified, default to only logging 5xx errors.
*/
logAllErrors?: boolean;
}
/**
* A utility to configure common middleware.
*
* @public
*/
export class MiddlewareFactory {
#config: ConfigService;
#logger: LoggerService;
/**
* Creates a new {@link MiddlewareFactory}.
*/
static create(options: MiddlewareFactoryOptions) {
return new MiddlewareFactory(options);
}
private constructor(options: MiddlewareFactoryOptions) {
this.#config = options.config;
this.#logger = options.logger;
}
/**
* Returns a middleware that unconditionally produces a 404 error response.
*
* @remarks
*
* Typically you want to place this middleware at the end of the chain, such
* that it's the last one attempted after no other routes matched.
*
* @returns An Express request handler
*/
notFound(): RequestHandler {
return (_req: Request, res: Response) => {
res.status(404).end();
};
}
/**
* Returns the compression middleware.
*
* @remarks
*
* The middleware will attempt to compress response bodies for all requests
* that traverse through the middleware.
*/
compression(): RequestHandler {
return compression();
}
/**
* Returns a request logging middleware.
*
* @remarks
*
* Typically you want to place this middleware at the start of the chain, such
* that it always logs requests whether they are "caught" by handlers farther
* down or not.
*
* @returns An Express request handler
*/
logging(): RequestHandler {
const logger = this.#logger.child({
type: 'incomingRequest',
});
return morgan('combined', {
stream: {
write(message: string) {
logger.info(message.trimEnd());
},
},
});
}
/**
* Returns a middleware that implements the helmet library.
*
* @remarks
*
* This middleware applies security policies to incoming requests and outgoing
* responses. It is configured using config keys such as `backend.csp`.
*
* @see {@link https://helmetjs.github.io/}
*
* @returns An Express request handler
*/
helmet(): RequestHandler {
return helmet(readHelmetOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Returns a middleware that implements the cors library.
*
* @remarks
*
* This middleware handles CORS. It is configured using the config key
* `backend.cors`.
*
* @see {@link https://github.com/expressjs/cors}
*
* @returns An Express request handler
*/
cors(): RequestHandler {
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Express middleware to handle errors during request processing.
*
* @remarks
*
* This is commonly the very last middleware in the chain.
*
* Its primary purpose is not to do translation of business logic exceptions,
* but rather to be a global catch-all for uncaught "fatal" errors that are
* expected to result in a 500 error. However, it also does handle some common
* error types (such as http-error exceptions, and the well-known error types
* in the `@backstage/errors` package) and returns the enclosed status code
* accordingly.
*
* It will also produce a response body with a serialized form of the error,
* unless a previous handler already did send a body. See
* {@link @backstage/errors#ErrorResponseBody} for the response shape used.
*
* @returns An Express error request handler
*/
error(options: MiddlewareFactoryErrorOptions = {}): ErrorRequestHandler {
const showStackTraces =
options.showStackTraces ?? process.env.NODE_ENV === 'development';
const logger = this.#logger.child({
type: 'errorHandler',
});
return (error: Error, req: Request, res: Response, next: NextFunction) => {
const statusCode = getStatusCode(error);
if (options.logAllErrors || statusCode >= 500) {
logger.error(`Request failed with status ${statusCode}`, error);
}
if (res.headersSent) {
// If the headers have already been sent, do not send the response again
// as this will throw an error in the backend.
next(error);
return;
}
const body: ErrorResponseBody = {
error: serializeError(error, { includeStack: showStackTraces }),
request: { method: req.method, url: req.url },
response: { statusCode },
};
res.status(statusCode).json(body);
};
}
}
function getStatusCode(error: Error): number {
// Look for common http library status codes
const knownStatusCodeFields = ['statusCode', 'status'];
for (const field of knownStatusCodeFields) {
const statusCode = (error as any)[field];
if (
typeof statusCode === 'number' &&
(statusCode | 0) === statusCode && // is whole integer
statusCode >= 100 &&
statusCode <= 599
) {
return statusCode;
}
}
// Handle well-known error types
switch (error.name) {
case NotModifiedError.name:
return 304;
case InputError.name:
return 400;
case AuthenticationError.name:
return 401;
case NotAllowedError.name:
return 403;
case NotFoundError.name:
return 404;
case ConflictError.name:
return 409;
default:
break;
}
// Fall back to internal server error
return 500;
}
@@ -0,0 +1,87 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readHttpServerOptions } from './config';
describe('readHttpServerOptions', () => {
it('should return defaults', () => {
expect(readHttpServerOptions()).toEqual({
listen: { host: '', port: 7007 },
});
});
it.each([
[{}, { listen: { host: '', port: 7007 } }],
[{ listen: ':80' }, { listen: { host: '', port: 80 } }],
[{ listen: '80' }, { listen: { host: '', port: 80 } }],
[{ listen: '1.2.3.4:80' }, { listen: { host: '1.2.3.4', port: 80 } }],
[{ listen: { host: '' } }, { listen: { host: '', port: 7007 } }],
[
{ listen: { host: '0.0.0.0' } },
{ listen: { host: '0.0.0.0', port: 7007 } },
],
[
{ listen: { host: '0.0.0.0', port: '80' } },
{ listen: { host: '0.0.0.0', port: 80 } },
],
[{ listen: { port: '80' } }, { listen: { host: '', port: 80 } }],
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
[
{ baseUrl: 'http://example.com:8080', https: true },
{
listen: { host: '', port: 7007 },
https: { certificate: { type: 'generated', hostname: 'example.com' } },
},
],
[
{ https: { certificate: { cert: 'my-cert', key: 'my-key' } } },
{
listen: { host: '', port: 7007 },
https: {
certificate: { type: 'plain', cert: 'my-cert', key: 'my-key' },
},
},
],
])('should read http server options %#', (input, output) => {
expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output);
});
it.each([
[
{ listen: { port: 'not-a-number' } },
"Unable to convert config value for key 'listen.port' in 'mock-config' to a number",
],
[
{ listen: { port: {} } },
"Invalid type in config for key 'listen.port' in 'mock-config', got object, wanted number",
],
[
{ listen: { host: false } },
"Invalid type in config for key 'listen.host' in 'mock-config', got boolean, wanted string",
],
[{ https: {} }, "Missing required config value at 'https.certificate.cert"],
[
{ https: { certificate: { cert: 'x' } } },
"Missing required config value at 'https.certificate.key",
],
])('should throw on bad options %#', (input, message) => {
expect(() => readHttpServerOptions(new ConfigReader(input))).toThrow(
message,
);
});
});
+101
View File
@@ -0,0 +1,101 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import { HttpServerOptions } from './types';
const DEFAULT_PORT = 7007;
const DEFAULT_HOST = '';
/**
* Reads {@link HttpServerOptions} from a {@link @backstage/config#Config} object.
*
* @public
* @remarks
*
* The provided configuration object should contain the `listen` and
* additional keys directly.
*
* @example
* ```ts
* const opts = readHttpServerOptions(config.getConfig('backend'));
* ```
*/
export function readHttpServerOptions(config?: Config): HttpServerOptions {
return {
listen: readHttpListenOptions(config),
https: readHttpsOptions(config),
};
}
function readHttpListenOptions(config?: Config): HttpServerOptions['listen'] {
const listen = config?.getOptional('listen');
if (typeof listen === 'string') {
const parts = String(listen).split(':');
const port = parseInt(parts[parts.length - 1], 10);
if (!isNaN(port)) {
if (parts.length === 1) {
return { port, host: DEFAULT_HOST };
}
if (parts.length === 2) {
return { host: parts[0], port };
}
}
throw new Error(
`Unable to parse listen address ${listen}, expected <port> or <host>:<port>`,
);
}
// Workaround to allow empty string
const host = config?.getOptional('listen.host') ?? DEFAULT_HOST;
if (typeof host !== 'string') {
config?.getOptionalString('listen.host'); // will throw
throw new Error('unreachable');
}
return {
port: config?.getOptionalNumber('listen.port') ?? DEFAULT_PORT,
host,
};
}
function readHttpsOptions(config?: Config): HttpServerOptions['https'] {
const https = config?.getOptional('https');
if (https === true) {
const baseUrl = config!.getString('baseUrl');
let hostname;
try {
hostname = new URL(baseUrl).hostname;
} catch (error) {
throw new Error(`Invalid baseUrl "${baseUrl}"`);
}
return { certificate: { type: 'generated', hostname } };
}
const cc = config?.getOptionalConfig('https');
if (!cc) {
return undefined;
}
return {
certificate: {
type: 'plain',
cert: cc.getString('certificate.cert'),
key: cc.getString('certificate.key'),
},
};
}
@@ -0,0 +1,102 @@
/*
* 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 * as http from 'http';
import * as https from 'https';
import stoppableServer from 'stoppable';
import { RequestListener } from 'http';
import { LoggerService } from '@backstage/backend-plugin-api';
import { HttpServerOptions, ExtendedHttpServer } from './types';
import { getGeneratedCertificate } from './getGeneratedCertificate';
/**
* Creates a Node.js HTTP or HTTPS server instance.
*
* @public
*/
export async function createHttpServer(
listener: RequestListener,
options: HttpServerOptions,
deps: { logger: LoggerService },
): Promise<ExtendedHttpServer> {
const server = await createServer(listener, options, deps);
const stopper = stoppableServer(server, 0);
// The stopper here is actually the server itself, so if we try
// to call stopper.stop() down in the stop implementation, we'll
// be calling ourselves.
const stopServer = stopper.stop.bind(stopper);
return Object.assign(server, {
start() {
return new Promise<void>((resolve, reject) => {
const handleStartupError = (error: Error) => {
server.close();
reject(error);
};
server.on('error', handleStartupError);
const { host, port } = options.listen;
server.listen(port, host, () => {
server.off('error', handleStartupError);
deps.logger.info(`Listening on ${host}:${port}`);
resolve();
});
});
},
stop() {
return new Promise<void>((resolve, reject) => {
stopServer((error?: Error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
},
port() {
const address = server.address();
if (typeof address === 'string' || address === null) {
throw new Error(`Unexpected server address '${address}'`);
}
return address.port;
},
});
}
async function createServer(
listener: RequestListener,
options: HttpServerOptions,
deps: { logger: LoggerService },
): Promise<http.Server> {
if (options.https) {
const { certificate } = options.https;
if (certificate.type === 'generated') {
const credentials = await getGeneratedCertificate(
certificate.hostname,
deps.logger,
);
return https.createServer(credentials, listener);
}
return https.createServer(certificate, listener);
}
return http.createServer(listener);
}
@@ -16,86 +16,16 @@
import fs from 'fs-extra';
import { resolve as resolvePath, dirname } from 'path';
import express from 'express';
import * as http from 'http';
import * as https from 'https';
import { LoggerService } from '@backstage/backend-plugin-api';
import { HttpsSettings } from './config';
import forge from 'node-forge';
const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1000;
const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
/**
* Creates a Http server instance based on an Express application.
*
* @param app - The Express application object
* @param logger - Optional Winston logger object
* @returns A Http server instance
*
*/
export function createHttpServer(
app: express.Express,
logger?: LoggerService,
): http.Server {
logger?.info('Initializing http server');
return http.createServer(app);
}
/**
* Creates a Https server instance based on an Express application.
*
* @param app - The Express application object
* @param httpsSettings - HttpsSettings for self-signed certificate generation
* @param logger - Optional Winston logger object
* @returns A Https server instance
*
*/
export async function createHttpsServer(
app: express.Express,
httpsSettings: HttpsSettings,
logger?: LoggerService,
): Promise<http.Server> {
logger?.info('Initializing https server');
let credentials: { key: string | Buffer; cert: string | Buffer };
if ('hostname' in httpsSettings?.certificate) {
credentials = await getGeneratedCertificate(
httpsSettings.certificate.hostname,
logger,
);
} else {
logger?.info('Loading certificate from config');
credentials = {
key: httpsSettings?.certificate?.key,
cert: httpsSettings?.certificate?.cert,
};
}
if (!credentials.key || !credentials.cert) {
throw new Error('Invalid HTTPS credentials');
}
return https.createServer(credentials, app) as http.Server;
}
function getCertificateExpiration(cert: string, logger?: LoggerService) {
try {
const crt = forge.pki.certificateFromPem(cert);
return crt.validity.notAfter.getTime() - Date.now();
} catch (error) {
logger?.warn(`Unable to parse self-signed certificate. ${error}`);
return 0;
}
}
async function getGeneratedCertificate(
export async function getGeneratedCertificate(
hostname: string,
logger?: LoggerService,
logger: LoggerService,
) {
const hasModules = await fs.pathExists('node_modules');
let certPath;
@@ -109,24 +39,30 @@ async function getGeneratedCertificate(
}
if (await fs.pathExists(certPath)) {
const cert = await fs.readFile(certPath);
const remainingMs = getCertificateExpiration(cert.toString(), logger);
if (remainingMs > FIVE_DAYS_IN_MS) {
logger?.info('Using existing self-signed certificate');
return {
key: cert,
cert,
};
try {
const cert = await fs.readFile(certPath);
const crt = forge.pki.certificateFromPem(cert.toString());
const remainingMs = crt.validity.notAfter.getTime() - Date.now();
if (remainingMs > FIVE_DAYS_IN_MS) {
logger.info('Using existing self-signed certificate');
return {
key: cert,
cert,
};
}
} catch (error) {
logger.warn(`Unable to use existing self-signed certificate, ${error}`);
}
}
logger?.info('Generating new self-signed certificate');
const newCert = await createCertificate(hostname);
logger.info('Generating new self-signed certificate');
const newCert = await generateCertificate(hostname);
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
return newCert;
}
async function createCertificate(hostname: string) {
async function generateCertificate(hostname: string) {
const attributes = [
{
name: 'commonName',
@@ -0,0 +1,30 @@
/*
* Copyright 2023 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 { readHttpServerOptions } from './config';
export { createHttpServer } from './createHttpServer';
export { MiddlewareFactory } from './MiddlewareFactory';
export type {
MiddlewareFactoryErrorOptions,
MiddlewareFactoryOptions,
} from './MiddlewareFactory';
export { readCorsOptions } from './readCorsOptions';
export { readHelmetOptions } from './readHelmetOptions';
export type {
ExtendedHttpServer,
HttpServerCertificateOptions,
HttpServerOptions,
} from './types';
@@ -0,0 +1,87 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readCorsOptions } from './readCorsOptions';
describe('readCorsOptions', () => {
it('should be disabled by default', () => {
expect(readCorsOptions()).toEqual({
origin: false,
});
});
it('reads single string', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.value', mockCallback); // valid origin
origin('http://a.value', mockCallback); // invalid origin
origin(undefined, mockCallback); // when not origin needs to reject the call
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(false);
expect(mockCallback.mock.calls[2][1]).toBe(false);
});
it('reads string array', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({
cors: {
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.b.c.value-9.com', mockCallback);
origin('http://a.value-999.com', mockCallback);
origin('http://a.value', mockCallback);
origin('http://a.valuex', mockCallback);
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[2][0]).toBe(null);
expect(mockCallback.mock.calls[3][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(true);
expect(mockCallback.mock.calls[2][1]).toBe(true);
expect(mockCallback.mock.calls[3][1]).toBe(false);
});
it('reads undefined origin', () => {
const config = new ConfigReader({
cors: {},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(expect.objectContaining({}));
expect(cors?.origin).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import { CorsOptions } from 'cors';
import { Minimatch } from 'minimatch';
/**
* Attempts to read a CORS options object from the backend configuration object.
*
* @public
* @param config - The backend configuration object.
* @returns A CORS options object, or undefined if no cors configuration is present.
*
* @example
* ```ts
* const corsOptions = readCorsOptions(config.getConfig('backend'));
* ```
*/
export function readCorsOptions(config?: Config): CorsOptions {
const cc = config?.getOptionalConfig('cors');
if (!cc) {
return { origin: false }; // Disable CORS
}
return {
origin: createCorsOriginMatcher(readStringArray(cc, 'origin')),
methods: readStringArray(cc, 'methods'),
allowedHeaders: readStringArray(cc, 'allowedHeaders'),
exposedHeaders: readStringArray(cc, 'exposedHeaders'),
credentials: cc.getOptionalBoolean('credentials'),
maxAge: cc.getOptionalNumber('maxAge'),
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
};
}
function readStringArray(config: Config, key: string): string[] | undefined {
const value = config.getOptional(key);
if (typeof value === 'string') {
return [value];
} else if (!value) {
return undefined;
}
return config.getStringArray(key);
}
function createCorsOriginMatcher(allowedOriginPatterns: string[] | undefined) {
if (!allowedOriginPatterns) {
return undefined;
}
const allowedOriginMatchers = allowedOriginPatterns.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
);
return (
origin: string | undefined,
callback: (
err: Error | null,
origin: boolean | string | RegExp | (boolean | string | RegExp)[],
) => void,
) => {
return callback(
null,
allowedOriginMatchers.some(pattern => pattern.match(origin ?? '')),
);
};
}
@@ -0,0 +1,80 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readHelmetOptions } from './readHelmetOptions';
describe('readHelmetOptions', () => {
it('should return defaults', () => {
expect(readHelmetOptions()).toEqual({
contentSecurityPolicy: {
useDefaults: false,
directives: {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https:', 'data:'],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'", "'unsafe-eval'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'script-src-attr': ["'none'"],
'upgrade-insecure-requests': [],
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
});
});
it('should add additional directives', () => {
const config = new ConfigReader({
csp: {
key: ['value'],
'img-src': false,
'script-src-attr': ['custom'],
},
});
expect(readHelmetOptions(config)).toEqual({
contentSecurityPolicy: {
useDefaults: false,
directives: {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https:', 'data:'],
'frame-ancestors': ["'self'"],
'object-src': ["'none'"],
'script-src': ["'self'", "'unsafe-eval'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'script-src-attr': ['custom'],
'upgrade-insecure-requests': [],
key: ['value'],
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
});
});
it('rejects invalid value types', () => {
const config = new ConfigReader({ csp: { key: [4] } });
expect(() => readHelmetOptions(config)).toThrow(/wanted string-array/);
});
});
@@ -0,0 +1,109 @@
/*
* 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 { Config } from '@backstage/config';
import helmet from 'helmet';
import { HelmetOptions } from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
/**
* Attempts to read Helmet options from the backend configuration object.
*
* @public
* @param config - The backend configuration object.
* @returns A Helmet options object, or undefined if no Helmet configuration is present.
*
* @example
* ```ts
* const helmetOptions = readHelmetOptions(config.getConfig('backend'));
* ```
*/
export function readHelmetOptions(config?: Config): HelmetOptions {
const cspOptions = readCspDirectives(config);
return {
contentSecurityPolicy: {
useDefaults: false,
directives: applyCspDirectives(cspOptions),
},
// These are all disabled in order to maintain backwards compatibility
// when bumping helmet v5. We can't enable these by default because
// there is no way for users to configure them.
// TODO(Rugvip): We should give control of this setup to consumers
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
};
}
type CspDirectives = Record<string, string[] | false> | undefined;
/**
* Attempts to read a CSP directives from the backend configuration object.
*
* @example
* ```yaml
* backend:
* csp:
* connect-src: ["'self'", 'http:', 'https:']
* upgrade-insecure-requests: false
* ```
*/
function readCspDirectives(config?: Config): CspDirectives {
const cc = config?.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
const result: Record<string, string[] | false> = {};
for (const key of cc.keys()) {
if (cc.get(key) === false) {
result[key] = false;
} else {
result[key] = cc.getStringArray(key);
}
}
return result;
}
export function applyCspDirectives(
directives: CspDirectives,
): ContentSecurityPolicyOptions['directives'] {
const result: ContentSecurityPolicyOptions['directives'] =
helmet.contentSecurityPolicy.getDefaultDirectives();
// TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval.
// It should be replaced by any other solution that doesn't require unsafe-eval.
result['script-src'] = ["'self'", "'unsafe-eval'"];
// TODO(Rugvip): This is removed so that we maintained backwards compatibility
// when bumping to helmet v5, we could remove this as well as
// skip setting `useDefaults: false` in the future.
delete result['form-action'];
if (directives) {
for (const [key, value] of Object.entries(directives)) {
if (value === false) {
delete result[key];
} else {
result[key] = value;
}
}
}
return result;
}
@@ -0,0 +1,61 @@
/*
* Copyright 2023 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 * as http from 'http';
/**
* An HTTP server extended with utility methods.
*
* @public
*/
export interface ExtendedHttpServer extends http.Server {
start(): Promise<void>;
stop(): Promise<void>;
port(): number;
}
/**
* Options for starting up an HTTP server.
*
* @public
*/
export type HttpServerOptions = {
listen: {
port: number;
host: string;
};
https?: {
certificate: HttpServerCertificateOptions;
};
};
/**
* Options for configuring HTTPS for an HTTP server.
*
* @public
*/
export type HttpServerCertificateOptions =
| {
type: 'plain';
key: string;
cert: string;
}
| {
type: 'generated';
hostname: string;
};
+1
View File
@@ -20,5 +20,6 @@
* @packageDocumentation
*/
export * from './http';
export * from './wiring';
export * from './services/implementations';
@@ -18,7 +18,7 @@ import {
HttpRouterService,
ServiceFactory,
} from '@backstage/backend-plugin-api';
import { httpRouterFactory } from './httpRouterService';
import { httpRouterFactory } from './httpRouterFactory';
describe('httpRouterFactory', () => {
it('should register plugin paths', async () => {
@@ -0,0 +1,18 @@
/*
* Copyright 2023 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 { httpRouterFactory } from './httpRouterFactory';
export type { HttpRouterFactoryOptions } from './httpRouterFactory';
@@ -14,6 +14,9 @@
* limitations under the License.
*/
export * from './httpRouter';
export * from './rootHttpRouter';
export { cacheFactory } from './cacheService';
export { configFactory } from './configService';
export { databaseFactory } from './databaseService';
@@ -24,9 +27,5 @@ export { permissionsFactory } from './permissionsService';
export { schedulerFactory } from './schedulerService';
export { tokenManagerFactory } from './tokenManagerService';
export { urlReaderFactory } from './urlReaderService';
export { httpRouterFactory } from './httpRouterService';
export { rootHttpRouterFactory } from './rootHttpRouterService';
export { lifecycleFactory } from './lifecycleService';
export { rootLifecycleFactory } from './rootLifecycleService';
export type { HttpRouterFactoryOptions } from './httpRouterService';
export type { RootHttpRouterFactoryOptions } from './rootHttpRouterService';
@@ -28,7 +28,7 @@ export const loggerFactory = createServiceFactory({
},
async factory({ rootLogger }) {
return async ({ plugin }) => {
return rootLogger.child({ pluginId: plugin.getId() });
return rootLogger.child({ plugin: plugin.getId() });
};
},
});
@@ -0,0 +1,51 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
describe('RestrictedIndexedRouter', () => {
it.each([
[['/b'], '/a'],
[['/a'], '/aa/b'],
[['/aa'], '/a/b'],
[['/a/b'], '/aa'],
[['/b/a'], '/a'],
[['/a'], '/aa'],
])(`with existing paths %s, adds %s without conflict`, (existing, added) => {
const router = new RestrictedIndexedRouter(false);
for (const path of existing) {
router.use(path, () => {});
}
expect(() => router.use(added, () => {})).not.toThrow();
});
it.each([
[['/a'], '/a', '/a'],
[['/a'], '/a/b', '/a'],
[['/a/b'], '/a', '/a/b'],
])(
`find conflict when existing paths %s, adds %s`,
(existing, added, conflict) => {
const router = new RestrictedIndexedRouter(false);
for (const path of existing) {
router.use(path, () => {});
}
expect(() => router.use(added, () => {})).toThrow(
`Path ${added} conflicts with the existing path ${conflict}`,
);
},
);
});
@@ -0,0 +1,73 @@
/*
* Copyright 2023 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 { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { Handler, Router } from 'express';
function normalizePath(path: string): string {
return path.replace(/\/*$/, '/');
}
export class RestrictedIndexedRouter implements RootHttpRouterService {
#indexPath?: false | string;
#router = Router();
#namedRoutes = Router();
#indexRouter = Router();
#existingPaths = new Array<string>();
constructor(indexPath?: false | string) {
this.#indexPath = indexPath;
this.#router.use(this.#namedRoutes);
this.#router.use(this.#indexRouter);
}
use(path: string, handler: Handler) {
if (path.match(/^[/\s]*$/)) {
throw new Error(`Root router path may not be empty`);
}
const conflictingPath = this.#findConflictingPath(path);
if (conflictingPath) {
throw new Error(
`Path ${path} conflicts with the existing path ${conflictingPath}`,
);
}
this.#existingPaths.push(path);
this.#namedRoutes.use(path, handler);
if (this.#indexPath === path) {
this.#indexRouter.use(handler);
}
}
handler(): Handler {
return this.#router;
}
#findConflictingPath(newPath: string): string | undefined {
const normalizedNewPath = normalizePath(newPath);
for (const path of this.#existingPaths) {
const normalizedPath = normalizePath(path);
if (normalizedPath.startsWith(normalizedNewPath)) {
return path;
}
if (normalizedNewPath.startsWith(normalizedPath)) {
return path;
}
}
return undefined;
}
}
@@ -0,0 +1,21 @@
/*
* Copyright 2023 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 { rootHttpRouterFactory } from './rootHttpRouterFactory';
export type {
RootHttpRouterFactoryOptions,
RootHttpRouterConfigureOptions,
} from './rootHttpRouterFactory';
@@ -0,0 +1,118 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ConfigService,
coreServices,
createServiceFactory,
LifecycleService,
LoggerService,
} from '@backstage/backend-plugin-api';
import express, { RequestHandler, Express } from 'express';
import {
createHttpServer,
MiddlewareFactory,
readHttpServerOptions,
} from '../../../http';
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
/**
* @public
*/
export interface RootHttpRouterConfigureOptions {
app: Express;
middleware: MiddlewareFactory;
routes: RequestHandler;
config: ConfigService;
logger: LoggerService;
lifecycle: LifecycleService;
}
/**
* @public
*/
export type RootHttpRouterFactoryOptions = {
/**
* The path to forward all unmatched requests to. Defaults to '/api/app'
*/
indexPath?: string | false;
configure?(options: RootHttpRouterConfigureOptions): void;
};
function defaultConfigure({
app,
routes,
middleware,
}: RootHttpRouterConfigureOptions) {
app.use(middleware.helmet());
app.use(middleware.cors());
app.use(middleware.compression());
app.use(middleware.logging());
app.use(routes);
app.use(middleware.notFound());
app.use(middleware.error());
}
/** @public */
export const rootHttpRouterFactory = createServiceFactory({
service: coreServices.rootHttpRouter,
deps: {
config: coreServices.config,
rootLogger: coreServices.rootLogger,
lifecycle: coreServices.rootLifecycle,
},
async factory(
{ config, rootLogger, lifecycle },
{
indexPath,
configure = defaultConfigure,
}: RootHttpRouterFactoryOptions = {},
) {
const router = new RestrictedIndexedRouter(indexPath ?? '/api/app');
const logger = rootLogger.child({ service: 'rootHttpRouter' });
const app = express();
const middleware = MiddlewareFactory.create({ config, logger });
configure({
app,
routes: router.handler(),
middleware,
config,
logger,
lifecycle,
});
const server = await createHttpServer(
app,
readHttpServerOptions(config.getOptionalConfig('backend')),
{ logger },
);
lifecycle.addShutdownHook({
async fn() {
await server.stop();
},
labels: { service: 'rootHttpRouter' },
});
await server.start();
return router;
},
});
@@ -1,31 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findConflictingPath } from './rootHttpRouterService';
describe('findConflictingPath', () => {
it('finds conflicts when present', () => {
expect(findConflictingPath(['/a'], '/a')).toBe('/a');
expect(findConflictingPath(['/b'], '/a')).toBe(undefined);
expect(findConflictingPath(['/a'], '/a/b')).toBe('/a');
expect(findConflictingPath(['/a'], '/aa/b')).toBe(undefined);
expect(findConflictingPath(['/aa'], '/a/b')).toBe(undefined);
expect(findConflictingPath(['/a/b'], '/a')).toBe('/a/b');
expect(findConflictingPath(['/a/b'], '/aa')).toBe(undefined);
expect(findConflictingPath(['/b/a'], '/a')).toBe(undefined);
expect(findConflictingPath(['/a'], '/aa')).toBe(undefined);
});
});
@@ -1,125 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceFactory,
coreServices,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
import { Handler } from 'express';
import { createServiceBuilder } from '@backstage/backend-common';
/**
* @public
*/
export type RootHttpRouterFactoryOptions = {
/**
* The path to forward all unmatched requests to. Defaults to '/api/app'
*/
indexPath?: string | false;
/**
* Middlewares that are added before all other routes.
*/
middleware?: Handler[];
};
/** @public */
export const rootHttpRouterFactory = createServiceFactory({
service: coreServices.rootHttpRouter,
deps: {
config: coreServices.config,
lifecycle: coreServices.rootLifecycle,
},
async factory({ config, lifecycle }, options?: RootHttpRouterFactoryOptions) {
const indexPath = options?.indexPath ?? '/api/app';
const namedRouter = Router();
const indexRouter = Router();
const service = createServiceBuilder(module).loadConfig(config);
for (const middleware of options?.middleware ?? []) {
service.addRouter('', middleware);
}
service.addRouter('', namedRouter).addRouter('', indexRouter);
const server = await service.start();
// Stop method isn't part of the public API, let's fix that once we move the implementation here.
const stoppableServer = server as typeof server & {
stop: (cb: (error?: Error) => void) => void;
};
lifecycle.addShutdownHook({
async fn() {
await new Promise<void>((resolve, reject) => {
stoppableServer.stop((error?: Error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
},
labels: { service: 'rootHttpRouter' },
});
const existingPaths = new Array<string>();
return {
use: (path: string, handler: Handler) => {
if (path.match(/^[/\s]*$/)) {
throw new Error(`Root router path may not be empty`);
}
const conflictingPath = findConflictingPath(existingPaths, path);
if (conflictingPath) {
throw new Error(
`Path ${path} conflicts with the existing path ${conflictingPath}`,
);
}
existingPaths.push(path);
namedRouter.use(path, handler);
if (indexPath === path) {
indexRouter.use(handler);
}
},
};
},
});
function normalizePath(path: string): string {
return path.replace(/\/*$/, '/');
}
export function findConflictingPath(
paths: string[],
newPath: string,
): string | undefined {
const normalizedNewPath = normalizePath(newPath);
for (const path of paths) {
const normalizedPath = normalizePath(path);
if (normalizedPath.startsWith(normalizedNewPath)) {
return path;
}
if (normalizedNewPath.startsWith(normalizedPath)) {
return path;
}
}
return undefined;
}
+2 -2
View File
@@ -17,8 +17,8 @@
import {
BackendFeature,
ExtensionPoint,
ServiceFactory,
ServiceRef,
ServiceFactoryOrFunction,
} from '@backstage/backend-plugin-api';
/**
@@ -42,7 +42,7 @@ export interface BackendRegisterInit {
* @public
*/
export interface CreateSpecializedBackendOptions {
services: (ServiceFactory | (() => ServiceFactory))[];
services: ServiceFactoryOrFunction[];
}
export interface ServiceHolder {
+1
View File
@@ -34,6 +34,7 @@
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
},
"dependencies": {
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/config": "workspace:^",
@@ -16,8 +16,7 @@
import { Config } from '@backstage/config';
import { PluginEndpointDiscovery } from './types';
import { readBaseOptions } from '../service/lib/config';
import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl';
import { readHttpServerOptions } from '@backstage/backend-app-api';
/**
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
@@ -43,9 +42,9 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery {
const basePath = options?.basePath ?? '/api';
const externalBaseUrl = config.getString('backend.baseUrl');
const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions(
config.getConfig('backend'),
);
const {
listen: { host: listenHost = '::', port: listenPort },
} = readHttpServerOptions(config.getConfig('backend'));
const protocol = config.has('backend.https') ? 'https' : 'http';
// Translate bind-all to localhost, and support IPv6
@@ -14,19 +14,11 @@
* limitations under the License.
*/
import {
AuthenticationError,
ConflictError,
ErrorResponseBody,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
serializeError,
} from '@backstage/errors';
import { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
import { ErrorRequestHandler } from 'express';
import { LoggerService } from '@backstage/backend-plugin-api';
import { getRootLogger } from '../logging';
import { ConfigReader } from '@backstage/config';
import { MiddlewareFactory } from '@backstage/backend-app-api';
/**
* Options passed to the {@link errorHandler} middleware.
@@ -73,69 +65,11 @@ export type ErrorHandlerOptions = {
export function errorHandler(
options: ErrorHandlerOptions = {},
): ErrorRequestHandler {
const showStackTraces =
options.showStackTraces ?? process.env.NODE_ENV === 'development';
const logger = (options.logger || getRootLogger()).child({
type: 'errorHandler',
return MiddlewareFactory.create({
config: new ConfigReader({}),
logger: options.logger ?? getRootLogger(),
}).error({
logAllErrors: options.logClientErrors,
showStackTraces: options.showStackTraces,
});
return (error: Error, req: Request, res: Response, next: NextFunction) => {
const statusCode = getStatusCode(error);
if (options.logClientErrors || statusCode >= 500) {
logger.error(`Request failed with status ${statusCode}`, error);
}
if (res.headersSent) {
// If the headers have already been sent, do not send the response again
// as this will throw an error in the backend.
next(error);
return;
}
const body: ErrorResponseBody = {
error: serializeError(error, { includeStack: showStackTraces }),
request: { method: req.method, url: req.url },
response: { statusCode },
};
res.status(statusCode).json(body);
};
}
function getStatusCode(error: Error): number {
// Look for common http library status codes
const knownStatusCodeFields = ['statusCode', 'status'];
for (const field of knownStatusCodeFields) {
const statusCode = (error as any)[field];
if (
typeof statusCode === 'number' &&
(statusCode | 0) === statusCode && // is whole integer
statusCode >= 100 &&
statusCode <= 599
) {
return statusCode;
}
}
// Handle well-known error types
switch (error.name) {
case NotModifiedError.name:
return 304;
case InputError.name:
return 400;
case AuthenticationError.name:
return 401;
case NotAllowedError.name:
return 403;
case NotFoundError.name:
return 404;
case ConflictError.name:
return 409;
default:
break;
}
// Fall back to internal server error
return 500;
}
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { MiddlewareFactory } from '@backstage/backend-app-api';
import { ConfigReader } from '@backstage/config';
import { RequestHandler } from 'express';
import { getRootLogger } from '../logging';
/**
* Express middleware to handle requests for missing routes.
@@ -26,8 +29,8 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
* @returns An Express request handler
*/
export function notFoundHandler(): RequestHandler {
/* eslint-disable @typescript-eslint/no-unused-vars */
return (_request: Request, response: Response, _next: NextFunction) => {
response.status(404).end();
};
return MiddlewareFactory.create({
config: new ConfigReader({}),
logger: getRootLogger(),
}).notFound();
}
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import { MiddlewareFactory } from '@backstage/backend-app-api';
import { RequestHandler } from 'express';
import { LoggerService } from '@backstage/backend-plugin-api';
import morgan from 'morgan';
import { getRootLogger } from '../logging';
import { ConfigReader } from '@backstage/config';
/**
* Logs incoming requests.
@@ -27,15 +28,8 @@ import { getRootLogger } from '../logging';
* @returns An Express request handler
*/
export function requestLoggingHandler(logger?: LoggerService): RequestHandler {
const actualLogger = (logger || getRootLogger()).child({
type: 'incomingRequest',
});
return morgan('combined', {
stream: {
write(message: string) {
actualLogger.info(message.trimEnd());
},
},
});
return MiddlewareFactory.create({
config: new ConfigReader({}),
logger: logger ?? getRootLogger(),
}).logging();
}
@@ -18,10 +18,9 @@ import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router, ErrorRequestHandler } from 'express';
import helmet from 'helmet';
import helmet, { HelmetOptions } from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
import * as http from 'http';
import stoppable from 'stoppable';
import { LoggerService } from '@backstage/backend-plugin-api';
import { useHotCleanup } from '../../hot';
import { getRootLogger } from '../../logging';
@@ -32,26 +31,20 @@ import {
} from '../../middleware';
import { RequestLoggingHandlerFactory, ServiceBuilder } from '../types';
import {
CspOptions,
HttpsSettings,
readBaseOptions,
readCorsOptions,
readCspOptions,
readHttpsSettings,
} from './config';
import { createHttpServer, createHttpsServer } from './hostFactory';
readHelmetOptions,
readHttpServerOptions,
HttpServerOptions,
createHttpServer,
} from '@backstage/backend-app-api';
export const DEFAULT_PORT = 7007;
// '' is express default, which listens to all interfaces
const DEFAULT_HOST = '';
export type CspOptions = Record<string, string[]>;
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
private host: string | undefined;
private logger: LoggerService | undefined;
private corsOptions: cors.CorsOptions | undefined;
private cspOptions: Record<string, string[] | false> | undefined;
private httpsSettings: HttpsSettings | undefined;
private serverOptions: HttpServerOptions;
private helmetOptions: HelmetOptions;
private corsOptions: cors.CorsOptions;
private routers: [string, Router][];
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
private errorHandler: ErrorRequestHandler | undefined;
@@ -64,50 +57,29 @@ export class ServiceBuilderImpl implements ServiceBuilder {
this.routers = [];
this.module = moduleRef;
this.useDefaultErrorHandler = true;
this.serverOptions = readHttpServerOptions();
this.corsOptions = readCorsOptions();
this.helmetOptions = readHelmetOptions();
}
loadConfig(config: Config): ServiceBuilder {
const backendConfig = config.getOptionalConfig('backend');
if (!backendConfig) {
return this;
}
const baseOptions = readBaseOptions(backendConfig);
if (baseOptions.listenPort) {
this.port =
typeof baseOptions.listenPort === 'string'
? parseInt(baseOptions.listenPort, 10)
: baseOptions.listenPort;
}
if (baseOptions.listenHost) {
this.host = baseOptions.listenHost;
}
const corsOptions = readCorsOptions(backendConfig);
if (corsOptions) {
this.corsOptions = corsOptions;
}
const cspOptions = readCspOptions(backendConfig);
if (cspOptions) {
this.cspOptions = cspOptions;
}
const httpsSettings = readHttpsSettings(backendConfig);
if (httpsSettings) {
this.httpsSettings = httpsSettings;
}
this.serverOptions = readHttpServerOptions(backendConfig);
this.corsOptions = readCorsOptions(backendConfig);
this.helmetOptions = readHelmetOptions(backendConfig);
return this;
}
setPort(port: number): ServiceBuilder {
this.port = port;
this.serverOptions.listen.port = port;
return this;
}
setHost(host: string): ServiceBuilder {
this.host = host;
this.serverOptions.listen.host = host;
return this;
}
@@ -116,8 +88,24 @@ export class ServiceBuilderImpl implements ServiceBuilder {
return this;
}
setHttpsSettings(settings: HttpsSettings): ServiceBuilder {
this.httpsSettings = settings;
setHttpsSettings(settings: {
certificate: { key: string; cert: string } | { hostname: string };
}): ServiceBuilder {
if ('hostname' in settings.certificate) {
this.serverOptions.https = {
certificate: {
...settings.certificate,
type: 'generated',
},
};
} else {
this.serverOptions.https = {
certificate: {
...settings.certificate,
type: 'plain',
},
};
}
return this;
}
@@ -127,7 +115,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
}
setCsp(options: CspOptions): ServiceBuilder {
this.cspOptions = options;
const csp = this.helmetOptions.contentSecurityPolicy;
this.helmetOptions.contentSecurityPolicy = {
...(typeof csp === 'object' ? csp : {}),
directives: applyCspDirectives(options),
};
return this;
}
@@ -155,13 +147,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
async start(): Promise<http.Server> {
const app = express();
const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
this.getOptions();
const logger = this.logger ?? getRootLogger();
app.use(helmet(helmetOptions));
if (corsOptions) {
app.use(cors(corsOptions));
}
app.use(helmet(this.helmetOptions));
app.use(cors(this.corsOptions));
app.use(compression());
app.use(
(this.requestLoggingHandler ?? defaultRequestLoggingHandler)(logger),
@@ -179,58 +168,23 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(defaultErrorHandler());
}
const server: http.Server = httpsSettings
? await createHttpsServer(app, httpsSettings, logger)
: createHttpServer(app, logger);
const stoppableServer = stoppable(server, 0);
const server = await createHttpServer(app, this.serverOptions, { logger });
useHotCleanup(this.module, () =>
stoppableServer.stop((e: any) => {
if (e) console.error(e);
server.stop().catch(error => {
console.error(error);
}),
);
return new Promise((resolve, reject) => {
function handleStartupError(e: unknown) {
server.close();
reject(e);
}
await server.start();
server.on('error', handleStartupError);
server.listen(port, host, () => {
server.off('error', handleStartupError);
logger.info(`Listening on ${host}:${port}`);
resolve(stoppableServer);
});
});
}
private getOptions() {
return {
port: this.port ?? DEFAULT_PORT,
host: this.host ?? DEFAULT_HOST,
logger: this.logger ?? getRootLogger(),
corsOptions: this.corsOptions,
httpsSettings: this.httpsSettings,
helmetOptions: {
contentSecurityPolicy: {
useDefaults: false,
directives: applyCspDirectives(this.cspOptions),
},
// These are all disabled in order to maintain backwards compatibility
// when bumping helmet v5. We can't enable these by default because
// there is no way for users to configure them.
// TODO(Rugvip): We should give control of this setup to consumers
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
},
};
return server;
}
}
// TODO(Rugvip): This is a duplicate of the same logic over in backend-app-api.
// It's needed as we don't want to export this helper from there, but need
// It to implement the setCsp method here.
export function applyCspDirectives(
directives: Record<string, string[] | false> | undefined,
): ContentSecurityPolicyOptions['directives'] {
@@ -1,108 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readCorsOptions, readCspOptions } from './config';
describe('config', () => {
describe('readCspOptions', () => {
it('reads valid values', () => {
const config = new ConfigReader({ csp: { key: ['value'] } });
expect(readCspOptions(config)).toEqual(
expect.objectContaining({
key: ['value'],
}),
);
});
it('accepts false', () => {
const config = new ConfigReader({ csp: { key: false } });
expect(readCspOptions(config)).toEqual(
expect.objectContaining({
key: false,
}),
);
});
it('rejects invalid value types', () => {
const config = new ConfigReader({ csp: { key: [4] } });
expect(() => readCspOptions(config)).toThrow(/wanted string-array/);
});
});
describe('readCorsOptions', () => {
it('reads single string', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.value', mockCallback); // valid origin
origin('http://a.value', mockCallback); // invalid origin
origin(undefined, mockCallback); // when not origin needs to reject the call
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(false);
expect(mockCallback.mock.calls[2][1]).toBe(false);
});
it('reads string array', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({
cors: {
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.b.c.value-9.com', mockCallback);
origin('http://a.value-999.com', mockCallback);
origin('http://a.value', mockCallback);
origin('http://a.valuex', mockCallback);
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[2][0]).toBe(null);
expect(mockCallback.mock.calls[3][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(true);
expect(mockCallback.mock.calls[2][1]).toBe(true);
expect(mockCallback.mock.calls[3][1]).toBe(false);
});
it('reads undefined origin', () => {
const config = new ConfigReader({
cors: {},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(expect.objectContaining({}));
expect(cors?.origin).toBeUndefined();
});
});
});
@@ -1,284 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { CorsOptions } from 'cors';
import { Minimatch } from 'minimatch';
export type BaseOptions = {
listenPort?: string | number;
listenHost?: string;
};
export type HttpsSettings = {
certificate: CertificateGenerationOptions | CertificateReferenceOptions;
};
export type CertificateReferenceOptions = {
key: string;
cert: string;
};
export type CertificateGenerationOptions = {
hostname: string;
};
export type CertificateAttributes = {
commonName: string;
};
/**
* A map from CSP directive names to their values.
*/
export type CspOptions = Record<string, string[]>;
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type CustomOrigin = (
requestOrigin: string | undefined,
callback: (err: Error | null, origin?: StaticOrigin) => void,
) => void;
/**
* Reads some base options out of a config object.
*
* @param config - The root of a backend config object
* @returns A base options object
*
* @example
* ```json
* {
* baseUrl: "http://localhost:7007",
* listen: "0.0.0.0:7007"
* }
* ```
*/
export function readBaseOptions(config: Config): BaseOptions {
if (typeof config.get('listen') === 'string') {
// TODO(freben): Expand this to support more addresses and perhaps optional
const { host, port } = parseListenAddress(config.getString('listen'));
return removeUnknown({
listenPort: port,
listenHost: host,
});
}
const port = config.getOptional('listen.port');
if (
typeof port !== 'undefined' &&
typeof port !== 'number' &&
typeof port !== 'string'
) {
throw new Error(
`Invalid type in config for key 'backend.listen.port', got ${typeof port}, wanted string or number`,
);
}
return removeUnknown({
listenPort: port,
listenHost: config.getOptionalString('listen.host'),
baseUrl: config.getOptionalString('baseUrl'),
});
}
/**
* Attempts to read a CORS options object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A CORS options object, or undefined if not specified
*
* @example
* ```json
* {
* cors: {
* origin: "http://localhost:3000",
* credentials: true
* }
* }
* ```
*/
export function readCorsOptions(config: Config): CorsOptions | undefined {
const cc = config.getOptionalConfig('cors');
if (!cc) {
return undefined;
}
return removeUnknown({
origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')),
methods: getOptionalStringOrStrings(cc, 'methods'),
allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'),
exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'),
credentials: cc.getOptionalBoolean('credentials'),
maxAge: cc.getOptionalNumber('maxAge'),
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
});
}
/**
* Attempts to read a CSP options object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A CSP options object, or undefined if not specified. Values can be
* false as well, which means to remove the default behavior for that
* key.
*
* @example
* ```yaml
* backend:
* csp:
* connect-src: ["'self'", 'http:', 'https:']
* upgrade-insecure-requests: false
* ```
*/
export function readCspOptions(
config: Config,
): Record<string, string[] | false> | undefined {
const cc = config.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
const result: Record<string, string[] | false> = {};
for (const key of cc.keys()) {
if (cc.get(key) === false) {
result[key] = false;
} else {
result[key] = cc.getStringArray(key);
}
}
return result;
}
/**
* Attempts to read a https settings object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A https settings object, or undefined if not specified
*
* @example
* ```json
* {
* https: {
* certificate: ...
* }
* }
* ```
*/
export function readHttpsSettings(config: Config): HttpsSettings | undefined {
const https = config.getOptional('https');
if (https === true) {
const baseUrl = config.getString('baseUrl');
let hostname;
try {
hostname = new URL(baseUrl).hostname;
} catch (error) {
throw new Error(`Invalid backend.baseUrl "${baseUrl}"`);
}
return { certificate: { hostname } };
}
const cc = config.getOptionalConfig('https');
if (!cc) {
return undefined;
}
const certificateConfig = cc.get('certificate');
const cfg = {
certificate: certificateConfig,
};
return removeUnknown(cfg as HttpsSettings);
}
function getOptionalStringOrStrings(
config: Config,
key: string,
): string | string[] | undefined {
const value = config.getOptional(key);
if (value === undefined || isStringOrStrings(value)) {
return value;
}
throw new Error(`Expected string or array of strings, got ${typeof value}`);
}
function createCorsOriginMatcher(
originValue: string | string[] | undefined,
): CustomOrigin | undefined {
if (originValue === undefined) {
return originValue;
}
if (!isStringOrStrings(originValue)) {
throw new Error(
`Expected string or array of strings, got ${typeof originValue}`,
);
}
const allowedOrigin =
typeof originValue === 'string' ? [originValue] : originValue;
const allowedOriginPatterns =
allowedOrigin?.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
) ?? [];
return (origin, callback) => {
return callback(
null,
allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')),
);
};
}
function isStringOrStrings(value: any): value is string | string[] {
return typeof value === 'string' || isStringArray(value);
}
function isStringArray(value: any): value is string[] {
if (!Array.isArray(value)) {
return false;
}
for (const v of value) {
if (typeof v !== 'string') {
return false;
}
}
return true;
}
function removeUnknown<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
) as T;
}
function parseListenAddress(value: string): { host?: string; port?: number } {
const parts = value.split(':');
if (parts.length === 1) {
return { port: parseInt(parts[0], 10) };
}
if (parts.length === 2) {
return { host: parts[0], port: parseInt(parts[1], 10) };
}
throw new Error(
`Unable to parse listen address ${value}, expected <port> or <host>:<port>`,
);
}
+2 -2
View File
@@ -4,7 +4,7 @@
```ts
import { Backend } from '@backstage/backend-app-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
// @public (undocumented)
export function createBackend(options?: CreateBackendOptions): Backend;
@@ -12,6 +12,6 @@ export function createBackend(options?: CreateBackendOptions): Backend;
// @public (undocumented)
export interface CreateBackendOptions {
// (undocumented)
services?: (ServiceFactory | (() => ServiceFactory))[];
services?: ServiceFactoryOrFunction[];
}
```
@@ -32,7 +32,7 @@ import {
tokenManagerFactory,
urlReaderFactory,
} from '@backstage/backend-app-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
export const defaultServiceFactories = [
cacheFactory(),
@@ -55,7 +55,7 @@ export const defaultServiceFactories = [
* @public
*/
export interface CreateBackendOptions {
services?: (ServiceFactory | (() => ServiceFactory))[];
services?: ServiceFactoryOrFunction[];
}
/**
+6 -1
View File
@@ -347,6 +347,11 @@ export interface ServiceFactoryConfig<
service: ServiceRef<TService, TScope>;
}
// @public
export type ServiceFactoryOrFunction<TService = unknown> =
| ServiceFactory<TService>
| (() => ServiceFactory<TService>);
// @public
export type ServiceRef<
TService,
@@ -364,7 +369,7 @@ export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
// (undocumented)
defaultFactory?: (
service: ServiceRef<TService, TScope>,
) => Promise<ServiceFactory<TService> | (() => ServiceFactory<TService>)>;
) => Promise<ServiceFactoryOrFunction<TService>>;
// (undocumented)
id: string;
// (undocumented)
@@ -20,5 +20,6 @@ export type {
TypesToServiceRef,
ServiceFactory,
ServiceFactoryConfig,
ServiceFactoryOrFunction,
} from './types';
export { createServiceRef, createServiceFactory } from './types';
@@ -71,13 +71,22 @@ export type ServiceFactory<TService = unknown> =
>;
};
/**
* Represents either a {@link ServiceFactory} or a function that returns one.
*
* @public
*/
export type ServiceFactoryOrFunction<TService = unknown> =
| ServiceFactory<TService>
| (() => ServiceFactory<TService>);
/** @public */
export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
id: string;
scope?: TScope;
defaultFactory?: (
service: ServiceRef<TService, TScope>,
) => Promise<ServiceFactory<TService> | (() => ServiceFactory<TService>)>;
) => Promise<ServiceFactoryOrFunction<TService>>;
}
/**
@@ -18,12 +18,12 @@ 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> {
const migrationsDir = resolvePackagePath(
'@backstage/backend-tasks',
'migrations',
);
await knex.migrate.latest({
directory: migrationsDir,
tableName: DB_MIGRATIONS_TABLE,
+2
View File
@@ -100,6 +100,8 @@ Options:
--no-docker
--mkdocs-port <PORT>
-v --verbose
--preview-app-bundle-path <PATH_TO_BUNDLE>
--preview-app-port <PORT>
-h, --help
```
@@ -18,6 +18,7 @@ import { Command } from 'commander';
import { TechdocsGenerator } from '@backstage/plugin-techdocs-node';
const defaultDockerImage = TechdocsGenerator.defaultDockerImage;
const defaultPreviewAppPort = '3000';
export function registerCommands(program: Command) {
program
@@ -251,6 +252,25 @@ export function registerCommands(program: Command) {
)
.option('--mkdocs-port <PORT>', 'Port for MkDocs server to use', '8000')
.option('-v --verbose', 'Enable verbose output.', false)
.option(
'--preview-app-bundle-path <PATH_TO_BUNDLE>',
'Preview documentation using another web app',
)
.option(
'--preview-app-port <PORT>',
'Port for the preview app to be served on',
defaultPreviewAppPort,
)
.hook('preAction', command => {
if (
command.opts().previewAppPort !== defaultPreviewAppPort &&
!command.opts().previewAppBundlePath
) {
command.error(
'--preview-app-port can only be used together with --preview-app-bundle-path',
);
}
})
.action(lazy(() => import('./serve/serve').then(m => m.default)));
}
@@ -42,6 +42,10 @@ function findPreviewBundlePath(): string {
}
}
function getPreviewAppPath(opts: OptionValues): string {
return opts.previewAppBundlePath ?? findPreviewBundlePath();
}
export default async function serve(opts: OptionValues) {
const logger = createLogger({ verbose: opts.verbose });
@@ -52,10 +56,6 @@ export default async function serve(opts: OptionValues) {
? true
: false;
// TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle
// a backstage app, we define app.baseUrl in the app-config.yaml.
// Hence, it is complicated to make this configurable.
const backstagePort = 3000;
const backstageBackendPort = 7007;
const mkdocsDockerAddr = `http://0.0.0.0:${opts.mkdocsPort}`;
@@ -115,9 +115,10 @@ export default async function serve(opts: OptionValues) {
);
}
const port = isDevMode ? backstageBackendPort : backstagePort;
const port = isDevMode ? backstageBackendPort : opts.previewAppPort;
const previewAppPath = getPreviewAppPath(opts);
const httpServer = new HTTPServer(
findPreviewBundlePath(),
previewAppPath,
port,
opts.mkdocsPort,
opts.verbose,
@@ -126,7 +127,7 @@ export default async function serve(opts: OptionValues) {
httpServer
.serve()
.catch(err => {
logger.error(err);
logger.error('Failed to start HTTP server', err);
mkdocsChildProcess.kill();
process.exit(1);
})
@@ -131,7 +131,7 @@ export class AwsS3EntityProvider implements EntityProvider {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -113,7 +113,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -152,7 +152,7 @@ export class BitbucketCloudEntityProvider
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -130,7 +130,7 @@ export class BitbucketServerEntityProvider implements EntityProvider {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -129,7 +129,7 @@ export class GerritEntityProvider implements EntityProvider {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -157,7 +157,7 @@ export class GithubEntityProvider implements EntityProvider, EventSubscriber {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -565,7 +565,7 @@ export class GithubOrgEntityProvider
try {
await this.read({ logger });
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -133,7 +133,7 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -111,7 +111,10 @@ export class IncrementalIngestionEngine implements IterationEngine {
);
const backoffLength = currentBackoff.as('milliseconds');
this.options.logger.error(error);
this.options.logger.error(
`incremental-engine: Ingestion '${ingestionId}' failed`,
error,
);
const truncatedError = stringifyError(error).substring(0, 700);
this.options.logger.error(
@@ -225,7 +225,7 @@ export class LdapOrgEntityProvider implements EntityProvider {
try {
await this.read({ logger });
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -351,7 +351,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider {
try {
await this.read({ logger });
} catch (error) {
logger.error(error);
logger.error(`${this.getProviderName()} refresh failed`, error);
}
},
});
@@ -100,7 +100,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher {
await this.sleep(this.waitTimeAfterEmptyReceiveMs);
}
} catch (error) {
logger.error(error);
logger.error('Failed to consume AWS SQS messages', error);
}
},
});
+4
View File
@@ -855,6 +855,10 @@ export class TaskWorker {
export type TemplateAction<Input extends JsonObject> = {
id: string;
description?: string;
examples?: {
description: string;
example: string;
}[];
supportsDryRun?: boolean;
schema?: {
input?: Schema;
@@ -19,6 +19,28 @@ import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { createTemplateAction } from '../../createTemplateAction';
import yaml from 'yaml';
const id = 'catalog:register';
const examples = [
{
description: 'Register with the catalog',
example: yaml.stringify({
steps: [
{
action: id,
id: 'register-with-catalog',
name: 'Register with the catalog',
input: {
catalogInfoUrl:
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
],
}),
},
];
/**
* Registers entities from a catalog descriptor file in the workspace into the software catalog.
@@ -34,9 +56,10 @@ export function createCatalogRegisterAction(options: {
| { catalogInfoUrl: string; optional?: boolean }
| { repoContentsUrl: string; catalogInfoPath?: string; optional?: boolean }
>({
id: 'catalog:register',
id,
description:
'Registers entities from a catalog descriptor file in the workspace into the software catalog.',
examples,
schema: {
input: {
oneOf: [
@@ -20,14 +20,47 @@ import * as yaml from 'yaml';
import { Entity } from '@backstage/catalog-model';
import { resolveSafeChildPath } from '@backstage/backend-common';
const id = 'catalog:write';
const examples = [
{
description: 'Write a catalog yaml file',
example: yaml.stringify({
steps: [
{
action: id,
id: 'create-catalog-info-file',
name: 'Create catalog file',
input: {
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'default/owner',
},
},
},
},
],
}),
},
];
/**
* Writes a catalog descriptor file containing the provided entity to a path in the workspace.
* @public
*/
export function createCatalogWriteAction() {
return createTemplateAction<{ filePath?: string; entity: Entity }>({
id: 'catalog:write',
id,
description: 'Writes the catalog-info.yaml for your template',
examples,
schema: {
input: {
type: 'object',
@@ -20,6 +20,7 @@ import os from 'os';
import { Writable } from 'stream';
import { createDebugLogAction } from './log';
import { join } from 'path';
import yaml from 'yaml';
describe('debug:log', () => {
const logStream = {
@@ -91,4 +92,43 @@ describe('debug:log', () => {
expect.stringContaining('Hello Backstage!'),
);
});
it('should log the workspace content from an example, if active', async () => {
const example = action.examples?.find(
sample => sample.description === 'List the workspace directory',
)?.example as string;
expect(typeof example).toEqual('string');
const context = {
...mockContext,
...yaml.parse(example).steps[0],
};
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
it('should log message from an example', async () => {
const example = action.examples?.find(
sample => sample.description === 'Write a debug message',
)?.example as string;
const context = {
...mockContext,
...yaml.parse(example).steps[0],
};
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
});
@@ -17,6 +17,42 @@
import { readdir, stat } from 'fs-extra';
import { relative, join } from 'path';
import { createTemplateAction } from '../../createTemplateAction';
import yaml from 'yaml';
const id = 'debug:log';
const examples = [
{
description: 'Write a debug message',
example: yaml.stringify({
steps: [
{
action: id,
id: 'write-debug-line',
name: 'Write "Hello Backstage!" log line',
input: {
message: 'Hello Backstage!',
},
},
],
}),
},
{
description: 'List the workspace directory',
example: yaml.stringify({
steps: [
{
action: id,
id: 'write-workspace-directory',
name: 'List the workspace directory',
input: {
listWorkspace: true,
},
},
],
}),
},
];
/**
* Writes a message into the log or lists all files in the workspace
@@ -30,9 +66,10 @@ import { createTemplateAction } from '../../createTemplateAction';
*/
export function createDebugLogAction() {
return createTemplateAction<{ message?: string; listWorkspace?: boolean }>({
id: 'debug:log',
id,
description:
'Writes a message into the log or lists all files in the workspace.',
examples,
schema: {
input: {
type: 'object',
@@ -66,6 +66,7 @@ export type ActionContext<Input extends JsonObject> = {
export type TemplateAction<Input extends JsonObject> = {
id: string;
description?: string;
examples?: { description: string; example: string }[];
supportsDryRun?: boolean;
schema?: {
input?: Schema;
@@ -292,6 +292,7 @@ export async function createRouter(
return {
id: action.id,
description: action.description,
examples: action.examples,
schema: action.schema,
};
});
+18 -8
View File
@@ -28,6 +28,23 @@ import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { UIOptionsType } from '@rjsf/utils';
import { UiSchema } from '@rjsf/utils';
// @public
export type Action = {
id: string;
description?: string;
schema?: {
input?: JSONSchema7;
output?: JSONSchema7;
};
examples?: ActionExample[];
};
// @public
export type ActionExample = {
description: string;
example: string;
};
// @alpha
export const createFieldValidation: () => FieldValidation_2;
@@ -102,14 +119,7 @@ export type FormProps = Pick<
>;
// @public
export type ListActionsResponse = Array<{
id: string;
description?: string;
schema?: {
input?: JSONSchema7;
output?: JSONSchema7;
};
}>;
export type ListActionsResponse = Array<Action>;
// @public
export type LogEvent = {
+21 -3
View File
@@ -44,18 +44,36 @@ export type ScaffolderTask = {
};
/**
* The response shape for the `listActions` call to the `scaffolder-backend`
* A single action example
*
* @public
*/
export type ListActionsResponse = Array<{
export type ActionExample = {
description: string;
example: string;
};
/**
* The response shape for a single action in the `listActions` call to the `scaffolder-backend`
*
* @public
*/
export type Action = {
id: string;
description?: string;
schema?: {
input?: JSONSchema7;
output?: JSONSchema7;
};
}>;
examples?: ActionExample[];
};
/**
* The response shape for the `listActions` call to the `scaffolder-backend`
*
* @public
*/
export type ListActionsResponse = Array<Action>;
/** @public */
export type ScaffolderOutputLink = {
@@ -13,9 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import React, { Fragment } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import {
ActionExample,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import {
Typography,
Paper,
@@ -27,10 +30,15 @@ import {
TableContainer,
TableHead,
TableRow,
Grid,
makeStyles,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@material-ui/core';
import { JSONSchema7, JSONSchema7Definition } from 'json-schema';
import classNames from 'classnames';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { useApi } from '@backstage/core-plugin-api';
import {
@@ -39,6 +47,7 @@ import {
Header,
Page,
ErrorPage,
CodeSnippet,
MarkdownContent,
} from '@backstage/core-components';
@@ -68,6 +77,34 @@ const useStyles = makeStyles(theme => ({
},
}));
const ExamplesTable = (props: { examples: ActionExample[] }) => {
return (
<Grid container>
{props.examples.map((example, index) => {
return (
<Fragment key={`example-${index}`}>
<Grid item lg={3}>
<Box padding={4}>
<Typography>{example.description}</Typography>
</Box>
</Grid>
<Grid item lg={9}>
<Box padding={1}>
<CodeSnippet
text={example.example}
showLineNumbers
showCopyCodeButton
language="yaml"
/>
</Box>
</Grid>
</Fragment>
);
})}
</Grid>
);
};
export const ActionsPage = () => {
const api = useApi(scaffolderApiRef);
const classes = useStyles();
@@ -181,6 +218,18 @@ export const ActionsPage = () => {
{renderTable(action.schema.output)}
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h5">Examples</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
});
-1
View File
@@ -19,7 +19,6 @@
*
* @packageDocumentation
*/
export { ScaffolderClient } from './api';
export {
@@ -141,6 +141,7 @@ export const adjustEntries = (
activeEntry && entry.id === activeEntry?.id
? entry.ring.color
: color(entry.ring.color).desaturate(0.5).lighten(0.1).string(),
active: activeEntry && entry.id === activeEntry?.id ? true : false,
};
});
@@ -68,8 +68,16 @@ const useStyles = makeStyles<Theme>(theme => ({
userSelect: 'none',
fontSize: '11px',
},
activeEntry: {
pointerEvents: 'visiblePainted',
userSelect: 'none',
fontSize: '11px',
background: '#6f6f6f',
color: '#FFF',
},
entryLink: {
pointerEvents: 'visiblePainted',
cursor: 'pointer',
},
}));
@@ -23,6 +23,7 @@ type RadarLegendLinkProps = {
description?: string;
title?: string;
classes: ClassNameMap<string>;
active?: boolean;
};
export const RadarLegendLink = ({
@@ -30,6 +31,7 @@ export const RadarLegendLink = ({
description,
title,
classes,
active,
}: RadarLegendLinkProps) => {
const [open, setOpen] = React.useState(false);
@@ -55,7 +57,9 @@ export const RadarLegendLink = ({
tabIndex={0}
onKeyPress={toggle}
>
<span className={classes.entry}>{title}</span>
<span className={active ? classes.activeEntry : classes.entry}>
{title}
</span>
</span>
{open && (
<RadarDescription
@@ -71,7 +75,9 @@ export const RadarLegendLink = ({
}
return (
<WithLink url={url} className={classes.entryLink}>
<span className={classes.entry}>{title}</span>
<span className={active ? classes.activeEntry : classes.entry}>
{title}
</span>
</WithLink>
);
};
@@ -57,6 +57,7 @@ export const RadarLegendRing = ({
url={entry.url}
title={entry.title}
description={entry.description}
active={entry.active}
/>
</li>
))}
+43 -45
View File
@@ -2966,90 +2966,90 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-darwin-arm64@npm:1.3.24"
"@swc/core-darwin-arm64@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-darwin-arm64@npm:1.3.25"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-darwin-x64@npm:1.3.24"
"@swc/core-darwin-x64@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-darwin-x64@npm:1.3.25"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.24"
"@swc/core-linux-arm-gnueabihf@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.25"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.24"
"@swc/core-linux-arm64-gnu@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.25"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm64-musl@npm:1.3.24"
"@swc/core-linux-arm64-musl@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm64-musl@npm:1.3.25"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-x64-gnu@npm:1.3.24"
"@swc/core-linux-x64-gnu@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-x64-gnu@npm:1.3.25"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-x64-musl@npm:1.3.24"
"@swc/core-linux-x64-musl@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-x64-musl@npm:1.3.25"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.24"
"@swc/core-win32-arm64-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.25"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.24"
"@swc/core-win32-ia32-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.25"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-x64-msvc@npm:1.3.24"
"@swc/core-win32-x64-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-x64-msvc@npm:1.3.25"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.3.9":
version: 1.3.24
resolution: "@swc/core@npm:1.3.24"
version: 1.3.25
resolution: "@swc/core@npm:1.3.25"
dependencies:
"@swc/core-darwin-arm64": 1.3.24
"@swc/core-darwin-x64": 1.3.24
"@swc/core-linux-arm-gnueabihf": 1.3.24
"@swc/core-linux-arm64-gnu": 1.3.24
"@swc/core-linux-arm64-musl": 1.3.24
"@swc/core-linux-x64-gnu": 1.3.24
"@swc/core-linux-x64-musl": 1.3.24
"@swc/core-win32-arm64-msvc": 1.3.24
"@swc/core-win32-ia32-msvc": 1.3.24
"@swc/core-win32-x64-msvc": 1.3.24
"@swc/core-darwin-arm64": 1.3.25
"@swc/core-darwin-x64": 1.3.25
"@swc/core-linux-arm-gnueabihf": 1.3.25
"@swc/core-linux-arm64-gnu": 1.3.25
"@swc/core-linux-arm64-musl": 1.3.25
"@swc/core-linux-x64-gnu": 1.3.25
"@swc/core-linux-x64-musl": 1.3.25
"@swc/core-win32-arm64-msvc": 1.3.25
"@swc/core-win32-ia32-msvc": 1.3.25
"@swc/core-win32-x64-msvc": 1.3.25
dependenciesMeta:
"@swc/core-darwin-arm64":
optional: true
@@ -3071,9 +3071,7 @@ __metadata:
optional: true
"@swc/core-win32-x64-msvc":
optional: true
bin:
swcx: run_swcx.js
checksum: a27b842be129b83c116f804e63deaa51dbd5d9b77d6260888d549f6408df1dd05aeef20046ceacc9fd7458e6afda6723545249bd77f77086b98bd9bf84738c19
checksum: de45a7dd871cc9497ad998d6a320d3c95cb9c74fdcb70590ff1f631e75001820d021bbfd5c463e9172afcb5ee47bffaa8fb893230e1329538c9f7afbd5ed45cf
languageName: node
linkType: hard
+69 -51
View File
@@ -3381,11 +3381,30 @@ __metadata:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@types/compression": ^1.7.0
"@types/cors": ^2.8.6
"@types/express": ^4.17.6
"@types/fs-extra": ^9.0.3
"@types/http-errors": ^2.0.0
"@types/morgan": ^1.9.0
"@types/node-forge": ^1.3.0
"@types/stoppable": ^1.1.0
compression: ^1.7.4
cors: ^2.8.5
express: ^4.17.1
express-promise-router: ^4.1.0
fs-extra: 10.1.0
helmet: ^6.0.0
http-errors: ^2.0.0
minimatch: ^5.0.0
morgan: ^1.10.0
node-forge: ^1.3.1
selfsigned: ^2.0.0
stoppable: ^1.1.0
supertest: ^6.1.3
winston: ^3.2.1
languageName: unknown
linkType: soft
@@ -3394,6 +3413,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/backend-common@workspace:packages/backend-common"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
@@ -12922,8 +12942,8 @@ __metadata:
linkType: hard
"@rjsf/utils@npm:^5.0.0-beta.14":
version: 5.0.0-beta.15
resolution: "@rjsf/utils@npm:5.0.0-beta.15"
version: 5.0.0-beta.16
resolution: "@rjsf/utils@npm:5.0.0-beta.16"
dependencies:
json-schema-merge-allof: ^0.8.1
jsonpointer: ^5.0.1
@@ -12932,7 +12952,7 @@ __metadata:
react-is: ^18.2.0
peerDependencies:
react: ^16.14.0 || >=17
checksum: e22b1cc13183d6a6a57b900bb80825eff1c4680edadb554adb6737c21237c7862b2a249e5b67c0c6971094b5d21224daf108ad1f400286be924bbc5c29784a00
checksum: 7a0f04f20b1b12eff33322bef322ee96e222fc621142fb34906cb18386a2a0dd97224fd97851899644994a1adaab33c6f107076f344b66108219fcb809132703
languageName: node
linkType: hard
@@ -13552,90 +13572,90 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-darwin-arm64@npm:1.3.24"
"@swc/core-darwin-arm64@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-darwin-arm64@npm:1.3.25"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-darwin-x64@npm:1.3.24"
"@swc/core-darwin-x64@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-darwin-x64@npm:1.3.25"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.24"
"@swc/core-linux-arm-gnueabihf@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.25"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.24"
"@swc/core-linux-arm64-gnu@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.25"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-arm64-musl@npm:1.3.24"
"@swc/core-linux-arm64-musl@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-arm64-musl@npm:1.3.25"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-x64-gnu@npm:1.3.24"
"@swc/core-linux-x64-gnu@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-x64-gnu@npm:1.3.25"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-linux-x64-musl@npm:1.3.24"
"@swc/core-linux-x64-musl@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-linux-x64-musl@npm:1.3.25"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.24"
"@swc/core-win32-arm64-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.25"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.24"
"@swc/core-win32-ia32-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.25"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.3.24":
version: 1.3.24
resolution: "@swc/core-win32-x64-msvc@npm:1.3.24"
"@swc/core-win32-x64-msvc@npm:1.3.25":
version: 1.3.25
resolution: "@swc/core-win32-x64-msvc@npm:1.3.25"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.3.9":
version: 1.3.24
resolution: "@swc/core@npm:1.3.24"
version: 1.3.25
resolution: "@swc/core@npm:1.3.25"
dependencies:
"@swc/core-darwin-arm64": 1.3.24
"@swc/core-darwin-x64": 1.3.24
"@swc/core-linux-arm-gnueabihf": 1.3.24
"@swc/core-linux-arm64-gnu": 1.3.24
"@swc/core-linux-arm64-musl": 1.3.24
"@swc/core-linux-x64-gnu": 1.3.24
"@swc/core-linux-x64-musl": 1.3.24
"@swc/core-win32-arm64-msvc": 1.3.24
"@swc/core-win32-ia32-msvc": 1.3.24
"@swc/core-win32-x64-msvc": 1.3.24
"@swc/core-darwin-arm64": 1.3.25
"@swc/core-darwin-x64": 1.3.25
"@swc/core-linux-arm-gnueabihf": 1.3.25
"@swc/core-linux-arm64-gnu": 1.3.25
"@swc/core-linux-arm64-musl": 1.3.25
"@swc/core-linux-x64-gnu": 1.3.25
"@swc/core-linux-x64-musl": 1.3.25
"@swc/core-win32-arm64-msvc": 1.3.25
"@swc/core-win32-ia32-msvc": 1.3.25
"@swc/core-win32-x64-msvc": 1.3.25
dependenciesMeta:
"@swc/core-darwin-arm64":
optional: true
@@ -13657,9 +13677,7 @@ __metadata:
optional: true
"@swc/core-win32-x64-msvc":
optional: true
bin:
swcx: run_swcx.js
checksum: a27b842be129b83c116f804e63deaa51dbd5d9b77d6260888d549f6408df1dd05aeef20046ceacc9fd7458e6afda6723545249bd77f77086b98bd9bf84738c19
checksum: de45a7dd871cc9497ad998d6a320d3c95cb9c74fdcb70590ff1f631e75001820d021bbfd5c463e9172afcb5ee47bffaa8fb893230e1329538c9f7afbd5ed45cf
languageName: node
linkType: hard
@@ -26834,9 +26852,9 @@ __metadata:
linkType: hard
"js-base64@npm:^3.6.0":
version: 3.7.3
resolution: "js-base64@npm:3.7.3"
checksum: ee19bed9ba21693e4583a47773dd701938de63db82e7c324d4c19093046157f0d87905cc20540f194f1d0ec88d6e1bfc4056aea916bcb873ebcf95942d26aa5a
version: 3.7.4
resolution: "js-base64@npm:3.7.4"
checksum: b8f6b10033b57bbb87fa34629f44bc665cb8b7bcba49610a29b296592616e633e9c186afaa02de5a54d60c08aaa36c8c8ad585db095a96fb331fe022f54792a2
languageName: node
linkType: hard