diff --git a/.changeset/cuddly-fishes-switch.md b/.changeset/cuddly-fishes-switch.md new file mode 100644 index 0000000000..75f09c16b7 --- /dev/null +++ b/.changeset/cuddly-fishes-switch.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/backend-app-api': patch +--- + +Use new `ServiceFactoryOrFunction` type. diff --git a/.changeset/dry-camels-scream.md b/.changeset/dry-camels-scream.md new file mode 100644 index 0000000000..ea35cece7a --- /dev/null +++ b/.changeset/dry-camels-scream.md @@ -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 diff --git a/.changeset/eight-mugs-draw.md b/.changeset/eight-mugs-draw.md new file mode 100644 index 0000000000..9d530e2a6d --- /dev/null +++ b/.changeset/eight-mugs-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added `ServiceFactoryOrFunction` type, for use when either a `ServiceFactory` or `() => ServiceFactory` can be used. diff --git a/.changeset/fair-seals-fry.md b/.changeset/fair-seals-fry.md new file mode 100644 index 0000000000..09b8970357 --- /dev/null +++ b/.changeset/fair-seals-fry.md @@ -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. diff --git a/.changeset/kind-badgers-know.md b/.changeset/kind-badgers-know.md new file mode 100644 index 0000000000..e01351cb5e --- /dev/null +++ b/.changeset/kind-badgers-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-tasks': patch +--- + +Minor internal refactor to avoid import cycle issue. diff --git a/.changeset/metal-ducks-promise.md b/.changeset/metal-ducks-promise.md new file mode 100644 index 0000000000..7d39edff44 --- /dev/null +++ b/.changeset/metal-ducks-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': minor +--- + +Add highlighting of legend item and show bubble on hover within the Tech Radar diff --git a/.changeset/metal-nails-punch.md b/.changeset/metal-nails-punch.md new file mode 100644 index 0000000000..a34c340c0a --- /dev/null +++ b/.changeset/metal-nails-punch.md @@ -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" + } + } + } + } + } +] +``` diff --git a/.changeset/orange-spoons-approve.md b/.changeset/orange-spoons-approve.md new file mode 100644 index 0000000000..96b1eda6fc --- /dev/null +++ b/.changeset/orange-spoons-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Refactor to rely on `@backstage/backend-app-api` for the implementation of `createServiceBuilder`. diff --git a/.changeset/polite-books-suffer.md b/.changeset/polite-books-suffer.md new file mode 100644 index 0000000000..b2a3c88e35 --- /dev/null +++ b/.changeset/polite-books-suffer.md @@ -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`. diff --git a/.changeset/rude-gifts-pay.md b/.changeset/rude-gifts-pay.md new file mode 100644 index 0000000000..4440f2ffc1 --- /dev/null +++ b/.changeset/rude-gifts-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Show action example yaml on the scaffolder actions documentation page. diff --git a/.changeset/tame-spoons-exercise.md b/.changeset/tame-spoons-exercise.md new file mode 100644 index 0000000000..37c489cb32 --- /dev/null +++ b/.changeset/tame-spoons-exercise.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f658799a85..f3de7bf0e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/features/software-templates/writing-custom-field-extensions.md b/docs/features/software-templates/writing-custom-field-extensions.md index 1d28bd5db9..be00453795 100644 --- a/docs/features/software-templates/writing-custom-field-extensions.md +++ b/docs/features/software-templates/writing-custom-field-extensions.md @@ -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} - /> + > + Name + onChange(e.target?.value)} + /> + + Use only letters, numbers, hyphens and underscores + + ); }; @@ -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 = ( ... }> - + ... @@ -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 diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md index 06eb83dfca..682c6a8f8f 100644 --- a/docs/features/techdocs/cli.md +++ b/docs/features/techdocs/cli.md @@ -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 The mkdocs docker container to use (default: "spotify/techdocs") - --docker-entrypoint Override the image entrypoint - --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 for MkDocs server to use (default: "8000") - -v --verbose Enable verbose output. (default: false) - -h, --help display help for command + -i, --docker-image The mkdocs docker container to use (default: "spotify/techdocs") + --docker-entrypoint Override the image entrypoint + --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 for MkDocs server to use (default: "8000") + --preview-app-bundle-path Preview documentation using a web app other than the included one. + --preview-app-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 diff --git a/microsite/data/plugins/toolbox.yaml b/microsite/data/plugins/toolbox.yaml new file mode 100644 index 0000000000..9d9adf0224 --- /dev/null +++ b/microsite/data/plugins/toolbox.yaml @@ -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' diff --git a/microsite/static/img/toolbox-logo.png b/microsite/static/img/toolbox-logo.png new file mode 100644 index 0000000000..33a16f5f49 Binary files /dev/null and b/microsite/static/img/toolbox-logo.png differ diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 1ff40c8b5d..cc84309a2f 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -3,10 +3,17 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + 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; +// @public +export function createHttpServer( + listener: RequestListener, + options: HttpServerOptions, + deps: { + logger: LoggerService; + }, +): Promise; + // @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; +// @public +export interface ExtendedHttpServer extends http.Server { + // (undocumented) + port(): number; + // (undocumented) + start(): Promise; + // (undocumented) + stop(): Promise; +} + // @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; +// @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; +// @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 diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 80598ec93d..ed1d2bca40 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -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", diff --git a/packages/backend-app-api/src/http/MiddlewareFactory.test.ts b/packages/backend-app-api/src/http/MiddlewareFactory.test.ts new file mode 100644 index 0000000000..64812564ca --- /dev/null +++ b/packages/backend-app-api/src/http/MiddlewareFactory.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/backend-app-api/src/http/MiddlewareFactory.ts b/packages/backend-app-api/src/http/MiddlewareFactory.ts new file mode 100644 index 0000000000..dc9fe9aa5f --- /dev/null +++ b/packages/backend-app-api/src/http/MiddlewareFactory.ts @@ -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; +} diff --git a/packages/backend-app-api/src/http/config.test.ts b/packages/backend-app-api/src/http/config.test.ts new file mode 100644 index 0000000000..f54ba927ca --- /dev/null +++ b/packages/backend-app-api/src/http/config.test.ts @@ -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, + ); + }); +}); diff --git a/packages/backend-app-api/src/http/config.ts b/packages/backend-app-api/src/http/config.ts new file mode 100644 index 0000000000..e3b41c63de --- /dev/null +++ b/packages/backend-app-api/src/http/config.ts @@ -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 or :`, + ); + } + + // 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'), + }, + }; +} diff --git a/packages/backend-app-api/src/http/createHttpServer.ts b/packages/backend-app-api/src/http/createHttpServer.ts new file mode 100644 index 0000000000..5c526ba1e2 --- /dev/null +++ b/packages/backend-app-api/src/http/createHttpServer.ts @@ -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 { + 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((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((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 { + 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); +} diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-app-api/src/http/getGeneratedCertificate.ts similarity index 55% rename from packages/backend-common/src/service/lib/hostFactory.ts rename to packages/backend-app-api/src/http/getGeneratedCertificate.ts index db4343cae5..b5cd420aff 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-app-api/src/http/getGeneratedCertificate.ts @@ -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 { - 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', diff --git a/packages/backend-app-api/src/http/index.ts b/packages/backend-app-api/src/http/index.ts new file mode 100644 index 0000000000..4a9ec14cf8 --- /dev/null +++ b/packages/backend-app-api/src/http/index.ts @@ -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'; diff --git a/packages/backend-app-api/src/http/readCorsOptions.test.ts b/packages/backend-app-api/src/http/readCorsOptions.test.ts new file mode 100644 index 0000000000..af490a26f9 --- /dev/null +++ b/packages/backend-app-api/src/http/readCorsOptions.test.ts @@ -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(); + }); +}); diff --git a/packages/backend-app-api/src/http/readCorsOptions.ts b/packages/backend-app-api/src/http/readCorsOptions.ts new file mode 100644 index 0000000000..b58cae5ded --- /dev/null +++ b/packages/backend-app-api/src/http/readCorsOptions.ts @@ -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 ?? '')), + ); + }; +} diff --git a/packages/backend-app-api/src/http/readHelmetOptions.test.ts b/packages/backend-app-api/src/http/readHelmetOptions.test.ts new file mode 100644 index 0000000000..bc31404634 --- /dev/null +++ b/packages/backend-app-api/src/http/readHelmetOptions.test.ts @@ -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/); + }); +}); diff --git a/packages/backend-app-api/src/http/readHelmetOptions.ts b/packages/backend-app-api/src/http/readHelmetOptions.ts new file mode 100644 index 0000000000..0555bdef00 --- /dev/null +++ b/packages/backend-app-api/src/http/readHelmetOptions.ts @@ -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 | 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 = {}; + 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; +} diff --git a/packages/backend-app-api/src/http/types.ts b/packages/backend-app-api/src/http/types.ts new file mode 100644 index 0000000000..ff96f69bff --- /dev/null +++ b/packages/backend-app-api/src/http/types.ts @@ -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; + + stop(): Promise; + + 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; + }; diff --git a/packages/backend-app-api/src/index.ts b/packages/backend-app-api/src/index.ts index 02633f3732..9527e35919 100644 --- a/packages/backend-app-api/src/index.ts +++ b/packages/backend-app-api/src/index.ts @@ -20,5 +20,6 @@ * @packageDocumentation */ +export * from './http'; export * from './wiring'; export * from './services/implementations'; diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts similarity index 97% rename from packages/backend-app-api/src/services/implementations/httpRouterService.test.ts rename to packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts index 89f6db2ac8..7717f4116e 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts @@ -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 () => { diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.ts similarity index 100% rename from packages/backend-app-api/src/services/implementations/httpRouterService.ts rename to packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.ts diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/index.ts b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts new file mode 100644 index 0000000000..f2db660414 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts @@ -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'; diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 2365ff36f4..73c3418090 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -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'; diff --git a/packages/backend-app-api/src/services/implementations/loggerService.ts b/packages/backend-app-api/src/services/implementations/loggerService.ts index 52b391249d..5cefe808f8 100644 --- a/packages/backend-app-api/src/services/implementations/loggerService.ts +++ b/packages/backend-app-api/src/services/implementations/loggerService.ts @@ -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() }); }; }, }); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts new file mode 100644 index 0000000000..b72e87f77b --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts @@ -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}`, + ); + }, + ); +}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts new file mode 100644 index 0000000000..961277f34d --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts @@ -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(); + + 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; + } +} diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts new file mode 100644 index 0000000000..1dfd72273d --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts @@ -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'; diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts new file mode 100644 index 0000000000..d8cedda0d9 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts @@ -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; + }, +}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts deleted file mode 100644 index fea99b0df9..0000000000 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts deleted file mode 100644 index dae7474793..0000000000 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts +++ /dev/null @@ -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((resolve, reject) => { - stoppableServer.stop((error?: Error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - }, - labels: { service: 'rootHttpRouter' }, - }); - - const existingPaths = new Array(); - - 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; -} diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index 115488d596..d3c4fc48c7 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -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 { diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index aff5e56abb..9c16472263 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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:^", diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index cc23a2f744..ded23b3374 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -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 diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 04b3f92e26..99ede75ab0 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -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; } diff --git a/packages/backend-common/src/middleware/notFoundHandler.ts b/packages/backend-common/src/middleware/notFoundHandler.ts index 2d8b0ef3d0..4f29b4f824 100644 --- a/packages/backend-common/src/middleware/notFoundHandler.ts +++ b/packages/backend-common/src/middleware/notFoundHandler.ts @@ -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(); } diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.ts b/packages/backend-common/src/middleware/requestLoggingHandler.ts index 1618f43ced..a03e3c31f6 100644 --- a/packages/backend-common/src/middleware/requestLoggingHandler.ts +++ b/packages/backend-common/src/middleware/requestLoggingHandler.ts @@ -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(); } diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index a3551416dd..f74dcd9b64 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -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; 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 | 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 { 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 | undefined, ): ContentSecurityPolicyOptions['directives'] { diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts deleted file mode 100644 index b80d475517..0000000000 --- a/packages/backend-common/src/service/lib/config.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts deleted file mode 100644 index 93cc12b7d4..0000000000 --- a/packages/backend-common/src/service/lib/config.ts +++ /dev/null @@ -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; - -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 | undefined { - const cc = config.getOptionalConfig('csp'); - if (!cc) { - return undefined; - } - - const result: Record = {}; - 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(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 or :`, - ); -} diff --git a/packages/backend-defaults/api-report.md b/packages/backend-defaults/api-report.md index 050ebde759..6cc58de663 100644 --- a/packages/backend-defaults/api-report.md +++ b/packages/backend-defaults/api-report.md @@ -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[]; } ``` diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 6f9e8e39b6..bef80e4f2b 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -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[]; } /** diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 2dcd0dccb4..6dc36c83b3 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -347,6 +347,11 @@ export interface ServiceFactoryConfig< service: ServiceRef; } +// @public +export type ServiceFactoryOrFunction = + | ServiceFactory + | (() => ServiceFactory); + // @public export type ServiceRef< TService, @@ -364,7 +369,7 @@ export interface ServiceRefConfig { // (undocumented) defaultFactory?: ( service: ServiceRef, - ) => Promise | (() => ServiceFactory)>; + ) => Promise>; // (undocumented) id: string; // (undocumented) diff --git a/packages/backend-plugin-api/src/services/system/index.ts b/packages/backend-plugin-api/src/services/system/index.ts index d00c2da4bd..cd15d8127d 100644 --- a/packages/backend-plugin-api/src/services/system/index.ts +++ b/packages/backend-plugin-api/src/services/system/index.ts @@ -20,5 +20,6 @@ export type { TypesToServiceRef, ServiceFactory, ServiceFactoryConfig, + ServiceFactoryOrFunction, } from './types'; export { createServiceRef, createServiceFactory } from './types'; diff --git a/packages/backend-plugin-api/src/services/system/types.ts b/packages/backend-plugin-api/src/services/system/types.ts index 7c3a9547b6..ffbbdb5f0c 100644 --- a/packages/backend-plugin-api/src/services/system/types.ts +++ b/packages/backend-plugin-api/src/services/system/types.ts @@ -71,13 +71,22 @@ export type ServiceFactory = >; }; +/** + * Represents either a {@link ServiceFactory} or a function that returns one. + * + * @public + */ +export type ServiceFactoryOrFunction = + | ServiceFactory + | (() => ServiceFactory); + /** @public */ export interface ServiceRefConfig { id: string; scope?: TScope; defaultFactory?: ( service: ServiceRef, - ) => Promise | (() => ServiceFactory)>; + ) => Promise>; } /** diff --git a/packages/backend-tasks/src/database/migrateBackendTasks.ts b/packages/backend-tasks/src/database/migrateBackendTasks.ts index cc0f4349ac..9530668e3e 100644 --- a/packages/backend-tasks/src/database/migrateBackendTasks.ts +++ b/packages/backend-tasks/src/database/migrateBackendTasks.ts @@ -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 { + const migrationsDir = resolvePackagePath( + '@backstage/backend-tasks', + 'migrations', + ); + await knex.migrate.latest({ directory: migrationsDir, tableName: DB_MIGRATIONS_TABLE, diff --git a/packages/techdocs-cli/cli-report.md b/packages/techdocs-cli/cli-report.md index cf5b2caf0f..9230fe9f1c 100644 --- a/packages/techdocs-cli/cli-report.md +++ b/packages/techdocs-cli/cli-report.md @@ -100,6 +100,8 @@ Options: --no-docker --mkdocs-port -v --verbose + --preview-app-bundle-path + --preview-app-port -h, --help ``` diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts index 03772d09d7..d4303ad306 100644 --- a/packages/techdocs-cli/src/commands/index.ts +++ b/packages/techdocs-cli/src/commands/index.ts @@ -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 for MkDocs server to use', '8000') .option('-v --verbose', 'Enable verbose output.', false) + .option( + '--preview-app-bundle-path ', + 'Preview documentation using another web app', + ) + .option( + '--preview-app-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))); } diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 68d0bbb277..d46c70678a 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -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); }) diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts index 80bc4d459b..b92fcfc022 100644 --- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts index b824d176ee..98b951bd46 100644 --- a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts +++ b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.ts index 83b6f447f2..a139bd7d8d 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.ts @@ -152,7 +152,7 @@ export class BitbucketCloudEntityProvider try { await this.refresh(logger); } catch (error) { - logger.error(error); + logger.error(`${this.getProviderName()} refresh failed`, error); } }, }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts index 447b71ee58..2ecdd79019 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts index ec08dbe4be..986f8ae60c 100644 --- a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts +++ b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts index 2a6a84c8af..1344e2f5d1 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts index 46ad089625..3093c2a897 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts @@ -565,7 +565,7 @@ export class GithubOrgEntityProvider try { await this.read({ logger }); } catch (error) { - logger.error(error); + logger.error(`${this.getProviderName()} refresh failed`, error); } }, }); diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts index cb4629b820..1cc818c811 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts index e2000c609e..e94bc9c53f 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.ts @@ -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( diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts index b38ea51cc0..64d56cc8bc 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts index 32892e7201..5c1a7f9e1c 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts @@ -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); } }, }); diff --git a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts index 4638769c45..e649e2ee83 100644 --- a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts +++ b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts @@ -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); } }, }); diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 66757a01cd..c89ead6d2b 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -855,6 +855,10 @@ export class TaskWorker { export type TemplateAction = { id: string; description?: string; + examples?: { + description: string; + example: string; + }[]; supportsDryRun?: boolean; schema?: { input?: Schema; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 4b9f883706..48fec3db03 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -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: [ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index da758898dc..5257839f2d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -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', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts index c53caff018..6b7ad8816f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts @@ -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!'), + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts index 99d030b772..3863623563 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -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', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index 666f7a0d50..0912cc038b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -66,6 +66,7 @@ export type ActionContext = { export type TemplateAction = { id: string; description?: string; + examples?: { description: string; example: string }[]; supportsDryRun?: boolean; schema?: { input?: Schema; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index e741961ddc..9ff8d43625 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -292,6 +292,7 @@ export async function createRouter( return { id: action.id, description: action.description, + examples: action.examples, schema: action.schema, }; }); diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index adffd29360..d4dac82104 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -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; // @public export type LogEvent = { diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index 554dc08ef5..0a517e7bff 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -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; /** @public */ export type ScaffolderOutputLink = { diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index c0957775c0..10a272b3cf 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -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 ( + + {props.examples.map((example, index) => { + return ( + + + + {example.description} + + + + + + + + + ); + })} + + ); +}; + export const ActionsPage = () => { const api = useApi(scaffolderApiRef); const classes = useStyles(); @@ -181,6 +218,18 @@ export const ActionsPage = () => { {renderTable(action.schema.output)} )} + {action.examples && ( + + }> + Examples + + + + + + + + )} ); }); diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 7c99f8039b..0e2b293982 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -19,7 +19,6 @@ * * @packageDocumentation */ - export { ScaffolderClient } from './api'; export { diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index 7fff038023..5c32207268 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -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, }; }); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 6177041c70..4ca0350e7b 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -68,8 +68,16 @@ const useStyles = makeStyles(theme => ({ userSelect: 'none', fontSize: '11px', }, + activeEntry: { + pointerEvents: 'visiblePainted', + userSelect: 'none', + fontSize: '11px', + background: '#6f6f6f', + color: '#FFF', + }, entryLink: { pointerEvents: 'visiblePainted', + cursor: 'pointer', }, })); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx index 3ed1b7574a..f75f23c12b 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx @@ -23,6 +23,7 @@ type RadarLegendLinkProps = { description?: string; title?: string; classes: ClassNameMap; + 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} > - {title} + + {title} + {open && ( - {title} + + {title} + ); }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx index f54e4ab3b3..9ad8126ac4 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx @@ -57,6 +57,7 @@ export const RadarLegendRing = ({ url={entry.url} title={entry.title} description={entry.description} + active={entry.active} /> ))} diff --git a/storybook/yarn.lock b/storybook/yarn.lock index 42c2bf1ad0..2a7fd49da3 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -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 diff --git a/yarn.lock b/yarn.lock index 5e1c17cdba..a549cab5a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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