diff --git a/.changeset/moody-buttons-hope.md b/.changeset/moody-buttons-hope.md new file mode 100644 index 0000000000..0b8f57f9bc --- /dev/null +++ b/.changeset/moody-buttons-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Marked the `TokenManagerService` and `IdentityService` types as deprecated diff --git a/.changeset/nervous-queens-fly.md b/.changeset/nervous-queens-fly.md new file mode 100644 index 0000000000..802034765c --- /dev/null +++ b/.changeset/nervous-queens-fly.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Refactored `TestDatabases` to no longer depend on `backend-common` diff --git a/.changeset/pretty-berries-live.md b/.changeset/pretty-berries-live.md new file mode 100644 index 0000000000..e83ebeef13 --- /dev/null +++ b/.changeset/pretty-berries-live.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/backend-common': patch +--- + +Deprecated `dropDatabase` diff --git a/.changeset/selfish-pugs-lick.md b/.changeset/selfish-pugs-lick.md new file mode 100644 index 0000000000..1ac7ac7321 --- /dev/null +++ b/.changeset/selfish-pugs-lick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +Import `tokenManager` definition from `@backstage/backend-plugin-api` diff --git a/docs/backend-system/core-services/permissions.md b/docs/backend-system/core-services/permissions.md index d979932c2f..06e966bbb2 100644 --- a/docs/backend-system/core-services/permissions.md +++ b/docs/backend-system/core-services/permissions.md @@ -16,36 +16,42 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; -import { Router } from 'express'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import Router from 'express-promise-router'; -createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'example', register(env) { env.registerInit({ deps: { permissions: coreServices.permissions, - http: coreServices.httpRouter, + httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, }, - async init({ permissions, http }) { - const router = Router(); - router.get('/test-me', (request, response) => { - // use the identity service to pull out the token from request headers - const { token } = await identity.getIdentity({ - request, - }); - - // ask the permissions framework what the decision is for the permission + async init({ permissions, httpRouter, httpAuth }) { + const endpoints = Router(); + endpoints.get('/test-me', (request, response) => { + // Ask the permissions framework what the decision is for the given + // permission, for the principal that made the original request. The + // `httpAuth` service helps us extract those credentials. We authorize + // a single permission here, so the result will be an array with one + // element accordingly. const permissionResponse = await permissions.authorize( - [ - { - permission: myCustomPermission, - }, - ], - { token }, + [{ permission: myCustomPermission }], + { credentials: await httpAuth.credentials(request) }, ); + + if (permissionResponse[0].result !== AuthorizeResult.ALLOW) { + throw new NotAllowedError( + 'You are not permitted to perform this action', + ); + } + + // TODO: Actual code goes here }); - http.use(router); + httpRouter.use(endpoints); }, }); }, diff --git a/docs/backend-system/core-services/plugin-metadata.md b/docs/backend-system/core-services/plugin-metadata.md index f045ed7f19..4038a40b6c 100644 --- a/docs/backend-system/core-services/plugin-metadata.md +++ b/docs/backend-system/core-services/plugin-metadata.md @@ -5,4 +5,28 @@ sidebar_label: Plugin Metadata description: Documentation for the Plugin Metadata service --- -TODO +This service allows you to query for metadata about the current plugin. In particular, this service is used by other plugin-scoped services, if they need to know what the ID is of the plugin that they are being instantiated for. + +## Using the service + +The following example shows a fake plugin-scoped service which wants to know what plugin it "belongs" to. + +```ts +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; + +export const myServiceFactory = createServiceFactory({ + service: myServiceRef, + deps: { + logger: coreServices.logger, + plugin: coreServices.pluginMetadata, + }, + async factory({ logger, plugin }) { + const pluginId = plugin.getId(); + logger.info(`Creating an instance of my service for plugin '${id}'`); + return ...; // TODO + }, +}); +``` diff --git a/package.json b/package.json index 4486c64afd..796059b9c6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:all": "backstage-cli repo build --all", "build:api-docs": "LANG=en_EN yarn build:api-reports --docs --exclude 'plugins/@(adr|adr-backend|adr-common|airbrake|airbrake-backend|allure|analytics-module-ga|analytics-module-ga4|analytics-module-newrelic-browser|apache-airflow|api-docs|api-docs-module-protoc-gen-doc|apollo-explorer|app-visualizer|azure-devops|azure-devops-backend|azure-devops-common|azure-sites|azure-sites-backend|azure-sites-common|badges|badges-backend|bazaar|bazaar-backend|bitbucket-cloud-common|bitrise|catalog-graph|catalog-graphql|catalog-import|catalog-unprocessed-entities|cicd-statistics|cicd-statistics-module-gitlab|circleci|cloudbuild|code-climate|code-coverage|code-coverage-backend|codescene|config-schema|cost-insights|cost-insights-common|dynatrace|entity-feedback|entity-feedback-backend|entity-feedback-common|entity-validation|example-todo-list|example-todo-list-backend|example-todo-list-common|firehydrant|fossa|gcalendar|gcp-projects|git-release-manager|github-actions|github-deployments|github-issues|github-pull-requests-board|gitops-profiles|gocd|graphiql|graphql-backend|graphql-voyager|ilert|jenkins|jenkins-backend|jenkins-common|kafka|kafka-backend|lighthouse|lighthouse-backend|lighthouse-common|linguist|linguist-backend|linguist-common|microsoft-calendar|newrelic|newrelic-dashboard|nomad|nomad-backend|octopus-deploy|opencost|pagerduty|periskop|periskop-backend|playlist|playlist-backend|playlist-common|proxy-backend|puppetdb|rollbar|rollbar-backend|sentry|shortcuts|splunk-on-call|stack-overflow|stack-overflow-backend|stackstorm|tech-radar|tech-radar-2|todo|todo-backend|xcmetrics)'", "build:api-reports": "yarn build:api-reports:only --tsc", - "build:api-reports:only": "backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)' -o ae-wrong-input-file-type --validate-release-tags", + "build:api-reports:only": "NODE_OPTIONS=--max-old-space-size=8192 backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)' -o ae-wrong-input-file-type --validate-release-tags", "build:backend": "yarn workspace example-backend build", "build:knip-reports": "backstage-repo-tools knip-reports", "build:plugins-report": "node ./scripts/build-plugins-report", diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 7e677bd570..758924b346 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -312,7 +312,7 @@ export class DockerContainerRunner implements ContainerRunner { runContainer(options: RunContainerOptions): Promise; } -// @public +// @public @deprecated export function dropDatabase( dbConfig: Config, ...databaseNames: string[] diff --git a/packages/backend-defaults/api-report-database.md b/packages/backend-defaults/api-report-database.md index 0edadaad59..598a3bd1b0 100644 --- a/packages/backend-defaults/api-report-database.md +++ b/packages/backend-defaults/api-report-database.md @@ -38,7 +38,7 @@ export const databaseServiceFactory: () => ServiceFactory< 'plugin' >; -// @public +// @public @deprecated export function dropDatabase( dbConfig: Config, ...databaseNames: string[] diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts index f19a848056..7e3adf17df 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts @@ -234,9 +234,10 @@ export class DatabaseManager implements LegacyRootDatabaseService { } /** - * Helper for deleting databases, only exists for backend-test-utils for now. + * Helper for deleting databases. * * @public + * @deprecated Will be removed in a future release. */ export async function dropDatabase( dbConfig: Config, diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 5e59378760..70ab3d8eed 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -24,41 +24,34 @@ import { Readable } from 'stream'; import { Request as Request_2 } from 'express'; import { Response as Response_2 } from 'express'; -// @public (undocumented) +// @public export interface AuthService { - // (undocumented) authenticate( token: string, options?: { allowLimitedAccess?: boolean; }, ): Promise; - // (undocumented) getLimitedUserToken( credentials: BackstageCredentials, ): Promise<{ token: string; expiresAt: Date; }>; - // (undocumented) getNoneCredentials(): Promise>; - // (undocumented) getOwnServiceCredentials(): Promise< BackstageCredentials >; - // (undocumented) getPluginRequestToken(options: { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string; }>; - // (undocumented) isPrincipal( credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; - // (undocumented) listPublicServiceKeys(): Promise<{ keys: JsonObject[]; }>; @@ -116,14 +109,14 @@ export interface BackendPluginRegistrationPoints { }): void; } -// @public (undocumented) +// @public export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; expiresAt?: Date; principal: TPrincipal; }; -// @public (undocumented) +// @public export type BackstageNonePrincipal = { type: 'none'; }; @@ -136,7 +129,7 @@ export type BackstagePrincipalAccessRestrictions = { }; }; -// @public (undocumented) +// @public export type BackstagePrincipalTypes = { user: BackstageUserPrincipal; service: BackstageServicePrincipal; @@ -144,14 +137,14 @@ export type BackstagePrincipalTypes = { unknown: unknown; }; -// @public (undocumented) +// @public export type BackstageServicePrincipal = { type: 'service'; subject: string; accessRestrictions?: BackstagePrincipalAccessRestrictions; }; -// @public (undocumented) +// @public export interface BackstageUserInfo { // (undocumented) ownershipEntityRefs: string[]; @@ -159,7 +152,7 @@ export interface BackstageUserInfo { userEntityRef: string; } -// @public (undocumented) +// @public export type BackstageUserPrincipal = { type: 'user'; userEntityRef: string; @@ -334,9 +327,8 @@ export type ExtensionPoint = { // @public @deprecated (undocumented) export type ExtensionPointConfig = CreateExtensionPointOptions; -// @public (undocumented) +// @public export interface HttpAuthService { - // (undocumented) credentials( req: Request_2, options?: { @@ -344,7 +336,6 @@ export interface HttpAuthService { allowLimitedAccess?: boolean; }, ): Promise>; - // (undocumented) issueUserCookie( res: Response_2, options?: { @@ -355,15 +346,13 @@ export interface HttpAuthService { }>; } -// @public (undocumented) +// @public export interface HttpRouterService { - // (undocumented) addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void; - // (undocumented) use(handler: Handler): void; } -// @public (undocumented) +// @public export interface HttpRouterServiceAuthPolicy { // (undocumented) allow: 'unauthenticated' | 'user-cookie'; @@ -371,7 +360,7 @@ export interface HttpRouterServiceAuthPolicy { path: string; } -// @public (undocumented) +// @public @deprecated export interface IdentityService extends IdentityApi {} export { isChildPath }; @@ -379,7 +368,7 @@ export { isChildPath }; // @public export function isDatabaseConflictError(e: unknown): boolean; -// @public (undocumented) +// @public export interface LifecycleService { addShutdownHook( hook: LifecycleServiceShutdownHook, @@ -421,14 +410,12 @@ export interface LoggerService { warn(message: string, meta?: Error | JsonObject): void; } -// @public (undocumented) +// @public export interface PermissionsService extends PermissionEvaluator { - // (undocumented) authorize( requests: AuthorizePermissionRequest[], options?: PermissionsServiceRequestOptions, ): Promise; - // (undocumented) authorizeConditional( requests: QueryPermissionRequest[], options?: PermissionsServiceRequestOptions, @@ -444,9 +431,8 @@ export type PermissionsServiceRequestOptions = credentials: BackstageCredentials; }; -// @public (undocumented) +// @public export interface PluginMetadataService { - // (undocumented) getId(): string; } @@ -535,18 +521,18 @@ export function resolvePackagePath(name: string, ...paths: string[]): string; // @public export function resolveSafeChildPath(base: string, path: string): string; -// @public (undocumented) +// @public export interface RootConfigService extends Config {} -// @public (undocumented) +// @public export interface RootHttpRouterService { use(path: string, handler: Handler): void; } -// @public (undocumented) +// @public export interface RootLifecycleService extends LifecycleService {} -// @public (undocumented) +// @public export interface RootLoggerService extends LoggerService {} // @public (undocumented) @@ -686,7 +672,7 @@ export interface ServiceRefConfig { scope?: TScope; } -// @public +// @public @deprecated export interface TokenManagerService { authenticate(token: string): Promise; getToken(): Promise<{ @@ -701,9 +687,8 @@ export interface UrlReaderService { search(url: string, options?: SearchOptions): Promise; } -// @public (undocumented) +// @public export interface UserInfoService { - // (undocumented) getUserInfo(credentials: BackstageCredentials): Promise; } ``` diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 000ce8bbb1..dce5e9be3b 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -18,15 +18,29 @@ import { PermissionAttributes } from '@backstage/plugin-permission-common'; import { JsonObject } from '@backstage/types'; /** + * Represents a user principal (for example when a user Backstage token issued + * by the auth backend was given to a request). + * + * @remarks + * + * Additional information about the user can be fetched using the + * {@link UserInfoService}. + * * @public */ export type BackstageUserPrincipal = { type: 'user'; + /** + * The entity ref of the user entity that this principal represents. + */ userEntityRef: string; }; /** + * Represents a principal that is not authenticated (for example when no token + * at all was given to a request). + * * @public */ export type BackstageNonePrincipal = { @@ -34,13 +48,22 @@ export type BackstageNonePrincipal = { }; /** + * Represents a service principal (for example when an external access method + * token was given to a request, or the caller was a Backstage backend plugin). * @public */ export type BackstageServicePrincipal = { type: 'service'; - // Exact format TBD, possibly 'plugin:' or 'external:' - subject: string; + /** + * A string that represents the service. + * + * @remarks + * + * This string is only informational, has no well defined semantics, and + * should never be used to drive actual logic in code. + */ + subject: string; // Exact format TBD, possibly 'plugin:' or 'external:' /** * The access restrictions that apply to this principal. @@ -92,17 +115,38 @@ export type BackstagePrincipalAccessRestrictions = { }; /** + * An opaque representation of credentials, for example as passed in a + * request-response flow. + * * @public */ export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + /** + * If the credentials have a limited lifetime, this is the time at which they + * expire and may no longer be accepted by a receiver. + */ expiresAt?: Date; + /** + * The principal (originator) of the request. + * + * @remarks + * + * This is semantically the originator of a request chain, and may or may not + * represent the immediate caller of your service. For example, in + * on-behalf-of scenarios, the immediate caller may be an intermediary backend + * service, but the principal may still be a user that was the original + * caller. + */ principal: TPrincipal; }; /** + * The types of principal that can be represented in a + * {@link BackstageCredentials} object. + * * @public */ export type BackstagePrincipalTypes = { @@ -113,36 +157,95 @@ export type BackstagePrincipalTypes = { }; /** + * Provides token authentication and credentials management. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/auth | service documentation} for more details. + * * @public */ export interface AuthService { + /** + * Verifies a token and returns the associated credentials. + */ authenticate( token: string, options?: { + /** + * If set to true, allow limited access tokens (such as cookies). + * + * If this flag is not set, or is set to false, calls with limited access + * tokens will lead to a {@link @backstage/errors#NotAllowedError} being + * thrown. + */ allowLimitedAccess?: boolean; }, ): Promise; + /** + * Checks if the given credentials are of the given type, and narrows the + * TypeScript type accordingly if there's a match. + */ isPrincipal( credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; + /** + * Create a credentials object that represents an unauthenticated caller. + */ getNoneCredentials(): Promise>; + /** + * Create a credentials object that represents the current service itself. + */ getOwnServiceCredentials(): Promise< BackstageCredentials >; + /** + * Issue a token that can be used for authenticating calls towards other + * backend plugins. + * + * @remarks + * + * This method should be called before each request. Do not cold on to the + * issued token and reuse it for future calls. + */ getPluginRequestToken(options: { + /** + * The credentials of the originator of the request. + * + * @remarks + * + * This is most commonly the result of + * {@link AuthService.getOwnServiceCredentials} when the current service is + * the originator, or the output of {@link HttpAuthService.credentials} when + * performing requests on behalf of an incoming request identity. + */ onBehalfOf: BackstageCredentials; + /** + * The ID of the plugin that the request is being made to. + */ targetPluginId: string; }): Promise<{ token: string }>; + /** + * Issue a limited user token that can be used e.g. in cookie flows. + */ getLimitedUserToken( + /** + * The credentials that this token should represent. Must be a user + * principal. Commonly the output of {@link HttpAuthService.credentials} is + * used as the input. + */ credentials: BackstageCredentials, ): Promise<{ token: string; expiresAt: Date }>; + /** + * Retrieve the public keys that have been used to sign tokens that were + * issued by this service. This list is periodically pruned from keys that are + * significantly past their expiry. + */ listPublicServiceKeys(): Promise<{ keys: JsonObject[]; }>; diff --git a/packages/backend-plugin-api/src/services/definitions/CacheService.ts b/packages/backend-plugin-api/src/services/definitions/CacheService.ts index ccf88823b0..3b13292a58 100644 --- a/packages/backend-plugin-api/src/services/definitions/CacheService.ts +++ b/packages/backend-plugin-api/src/services/definitions/CacheService.ts @@ -47,6 +47,8 @@ export type CacheServiceOptions = { * A pre-configured, storage agnostic cache service suitable for use by * Backstage plugins. * + * See the {@link https://backstage.io/docs/backend-system/core-services/cache | service documentation} for more details. + * * @public */ export interface CacheService { diff --git a/packages/backend-plugin-api/src/services/definitions/DatabaseService.ts b/packages/backend-plugin-api/src/services/definitions/DatabaseService.ts index 20b7b3b5dc..c7f0e1ccf1 100644 --- a/packages/backend-plugin-api/src/services/definitions/DatabaseService.ts +++ b/packages/backend-plugin-api/src/services/definitions/DatabaseService.ts @@ -17,7 +17,9 @@ import { Knex } from 'knex'; /** - * The DatabaseService manages access to databases that Plugins get. + * Manages access to databases that plugins get. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/database | service documentation} for more details. * * @public */ diff --git a/packages/backend-plugin-api/src/services/definitions/DiscoveryService.ts b/packages/backend-plugin-api/src/services/definitions/DiscoveryService.ts index c3f0820c28..af92a4abb5 100644 --- a/packages/backend-plugin-api/src/services/definitions/DiscoveryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/DiscoveryService.ts @@ -18,6 +18,10 @@ * The DiscoveryService is used to provide a mechanism for backend * plugins to discover the endpoints for itself or other backend plugins. * + * See the {@link https://backstage.io/docs/backend-system/core-services/discovery | service documentation} for more details. + * + * @remarks + * * The purpose of the discovery API is to allow for many different deployment * setups and routing methods through a central configuration, instead * of letting each individual plugin manage that configuration. @@ -32,13 +36,15 @@ export interface DiscoveryService { /** * Returns the internal HTTP base URL for a given plugin, without a trailing slash. * + * @remarks + * * The returned URL should point to an internal endpoint for the plugin, with * the shortest route possible. The URL should be used for service-to-service * communication within a Backstage backend deployment. * - * This method must always be called just before making a request, as opposed to - * fetching the URL when constructing an API client. That is to ensure that more - * flexible routing patterns can be supported. + * This method must always be called just before making each request, as opposed to + * fetching the URL once when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported where a different result might be returned each time. * * For example, asking for the URL for `catalog` may return something * like `http://10.1.2.3/api/catalog` @@ -48,6 +54,8 @@ export interface DiscoveryService { /** * Returns the external HTTP base backend URL for a given plugin, without a trailing slash. * + * @remarks + * * The returned URL should point to an external endpoint for the plugin, such that * it is reachable from the Backstage frontend and other external services. The returned * URL should be usable for example as a callback / webhook URL. diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 5d1a2090d4..718aa9b532 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -17,19 +17,84 @@ import { Request, Response } from 'express'; import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService'; -/** @public */ +/** + * Provides handling of credentials in an ongoing request. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/http-auth | service documentation} for more details. + * + * @public + */ export interface HttpAuthService { + /** + * Extracts the caller's credentials from a request. + * + * @remarks + * + * The credentials have been validated before returning, and are guaranteed to + * adhere to whatever policies have been added to this route using + * {@link HttpRouterService.addAuthPolicy}, if any. + * + * Further restrictions can be imposed by passing in options that control the + * allowed types of credential. + * + * You can narrow the returned credentials object to specific principal types + * using {@link AuthService.isPrincipal}. + */ credentials( + /** + * An Express request object. + */ req: Request, + /** + * Optional further restrictions. + */ options?: { + /** + * If specified, allow only principals of the given type(s). + * + * If the incoming credentials were not of a type that matched this + * restriction, a {@link @backstage/errors#NotAllowedError} is thrown. + * + * The default is to allow user and service principals. + */ allow?: Array; + /** + * If set to true, allow limited access tokens (such as cookies). + * + * If this flag is not set, or is set to false, calls with limited access + * tokens will lead to a {@link @backstage/errors#NotAllowedError} being + * thrown. + */ allowLimitedAccess?: boolean; }, ): Promise>; + /** + * Issues a limited access token as a cookie on the given response object. + * This is only possible for requests that were originally made with user + * credentials (such as a Backstage token). + * + * This must be called before sending any payload data. + */ issueUserCookie( + /** + * An Express response object. + */ res: Response, + /** + * Optional further settings. + */ options?: { + /** + * Issue the cookie for this specific credential. Must be a "user" type + * principal, or a "none" type (which leads to deleting the cookie). + * + * @remarks + * + * Normally you do not have to specify this option, because the default + * behavior is to extract the credentials from the request that + * corresponded to the given respnse. + */ credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date }>; diff --git a/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts b/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts index 695b337754..92f7e950f9 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts @@ -16,17 +16,51 @@ import { Handler } from 'express'; -/** @public */ +/** + * Options for {@link HttpRouterService.addAuthPolicy}. + * + * @public + */ export interface HttpRouterServiceAuthPolicy { path: string; allow: 'unauthenticated' | 'user-cookie'; } /** + * Allows plugins to register HTTP routes. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/http-router | service documentation} for more details. + * * @public */ export interface HttpRouterService { + /** + * Registers an Express request handler under the plugin's base router. This + * typically makes its base path `/api/`. + */ use(handler: Handler): void; + /** + * Adds an auth policy to the router. This is used to allow unauthenticated or + * cookie based access to parts of a plugin's API. + * + * @remarks + * + * The paths given follow the same pattern as the routers given to the `use` + * method, that is, they are relative to the plugin's base URL, and can + * contain placeholders. + * + * @example + * + * ```ts + * http.addAuthPolicy({ + * path: '/static/:id', + * allow: 'user-cookie', + * }); + * ``` + * + * This allows limited access tokens via cookies on the + * `/api//static/*` paths, but not unauthenticated access. + */ addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void; } diff --git a/packages/backend-plugin-api/src/services/definitions/IdentityService.ts b/packages/backend-plugin-api/src/services/definitions/IdentityService.ts index 330aaf6447..daa45b8200 100644 --- a/packages/backend-plugin-api/src/services/definitions/IdentityService.ts +++ b/packages/backend-plugin-api/src/services/definitions/IdentityService.ts @@ -16,5 +16,12 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; -/** @public */ +/** + * This is the legacy service for identity handling in Backstage. Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/identity | service documentation} for more details. + * + * @public + * @deprecated Please {@link https://backstage.io/docs/tutorials/auth-service-migration | migrate} to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead. + */ export interface IdentityService extends IdentityApi {} diff --git a/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts b/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts index e0641e0457..62ccc54f82 100644 --- a/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts +++ b/packages/backend-plugin-api/src/services/definitions/LifecycleService.ts @@ -47,6 +47,10 @@ export interface LifecycleServiceShutdownOptions { } /** + * Provides registration of plugin startup and shutdown lifecycle hooks. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/lifecycle | service documentation} for more details. + * * @public */ export interface LifecycleService { diff --git a/packages/backend-plugin-api/src/services/definitions/LoggerService.ts b/packages/backend-plugin-api/src/services/definitions/LoggerService.ts index 2e4edc60be..86831d4520 100644 --- a/packages/backend-plugin-api/src/services/definitions/LoggerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/LoggerService.ts @@ -19,6 +19,8 @@ import { JsonObject } from '@backstage/types'; /** * A service that provides a logging facility. * + * See the {@link https://backstage.io/docs/backend-system/core-services/logger | service documentation} for more details. + * * @public */ export interface LoggerService { diff --git a/packages/backend-plugin-api/src/services/definitions/PermissionsService.ts b/packages/backend-plugin-api/src/services/definitions/PermissionsService.ts index 1db15ce5e5..c5046a988b 100644 --- a/packages/backend-plugin-api/src/services/definitions/PermissionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/PermissionsService.ts @@ -37,13 +37,46 @@ export type PermissionsServiceRequestOptions = credentials: BackstageCredentials; }; -/** @public */ +/** + * Permission system integration for authorization of user/service actions. + * + * See the {@link https://backstage.io/docs/permissions/overview | permissions documentation} + * and the {@link https://backstage.io/docs/backend-system/core-services/permissions | service documentation} + * for more details. + * + * @public + */ export interface PermissionsService extends PermissionEvaluator { + /** + * Evaluates + * {@link @backstage/plugin-permission-common#Permission | Permissions} and + * returns definitive decisions. + * + * @remarks + * + * The returned array has the same number of items, in the same order, as the + * given requests. + */ authorize( requests: AuthorizePermissionRequest[], options?: PermissionsServiceRequestOptions, ): Promise; + /** + * Evaluates {@link @backstage/plugin-permission-common#ResourcePermission | ResourcePermissions} and returns both definitive and + * conditional decisions, depending on the configured + * {@link @backstage/plugin-permission-node#PermissionPolicy}. + * + * @remarks + * + * This method is useful when the + * caller needs more control over the processing of conditional decisions. For example, a plugin + * backend may want to use {@link @backstage/plugin-permission-common#PermissionCriteria | conditions} in a database query instead of + * evaluating each resource in memory. + * + * The returned array has the same number of items, in the same order, as the + * given requests. + */ authorizeConditional( requests: QueryPermissionRequest[], options?: PermissionsServiceRequestOptions, diff --git a/packages/backend-plugin-api/src/services/definitions/PluginMetadataService.ts b/packages/backend-plugin-api/src/services/definitions/PluginMetadataService.ts index 48328e4db9..4fc3c45fd7 100644 --- a/packages/backend-plugin-api/src/services/definitions/PluginMetadataService.ts +++ b/packages/backend-plugin-api/src/services/definitions/PluginMetadataService.ts @@ -15,8 +15,15 @@ */ /** + * Access metadata about the current plugin. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/plugin-metadata | service documentation} for more details. + * * @public */ export interface PluginMetadataService { + /** + * The ID of the current plugin. + */ getId(): string; } diff --git a/packages/backend-plugin-api/src/services/definitions/RootConfigService.ts b/packages/backend-plugin-api/src/services/definitions/RootConfigService.ts index 02343e4ee8..453a7cda37 100644 --- a/packages/backend-plugin-api/src/services/definitions/RootConfigService.ts +++ b/packages/backend-plugin-api/src/services/definitions/RootConfigService.ts @@ -17,6 +17,12 @@ import { Config } from '@backstage/config'; /** + * Provides access to static configuration. + * + * See the {@link https://backstage.io/docs/conf/ | configuration documentation} + * and the {@link https://backstage.io/docs/backend-system/core-services/root-config | service documentation} + * for more details. + * * @public */ export interface RootConfigService extends Config {} diff --git a/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts b/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts index e583e978fa..a1006b749b 100644 --- a/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts +++ b/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts @@ -17,6 +17,10 @@ import { Handler } from 'express'; /** + * HTTP route registration for root services. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/root-http-router | service documentation} for more details. + * * @public */ export interface RootHttpRouterService { diff --git a/packages/backend-plugin-api/src/services/definitions/RootLifecycleService.ts b/packages/backend-plugin-api/src/services/definitions/RootLifecycleService.ts index e0e09cb859..b962fa68ba 100644 --- a/packages/backend-plugin-api/src/services/definitions/RootLifecycleService.ts +++ b/packages/backend-plugin-api/src/services/definitions/RootLifecycleService.ts @@ -16,5 +16,11 @@ import { LifecycleService } from './LifecycleService'; -/** @public */ +/** + * Registration of backend startup and shutdown lifecycle hooks. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/root-lifecycle | service documentation} for more details. + * + * @public + */ export interface RootLifecycleService extends LifecycleService {} diff --git a/packages/backend-plugin-api/src/services/definitions/RootLoggerService.ts b/packages/backend-plugin-api/src/services/definitions/RootLoggerService.ts index ad318ef53f..5d79098e26 100644 --- a/packages/backend-plugin-api/src/services/definitions/RootLoggerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/RootLoggerService.ts @@ -16,5 +16,11 @@ import { LoggerService } from './LoggerService'; -/** @public */ +/** + * Root-level logging. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/root-logger | service documentation} for more details. + * + * @public + */ export interface RootLoggerService extends LoggerService {} diff --git a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts index ad0fc1cf3a..91edf27ad6 100644 --- a/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts @@ -285,6 +285,8 @@ export interface SchedulerServiceTaskRunner { /** * Deals with the scheduling of distributed tasks, for a given plugin. * + * See the {@link https://backstage.io/docs/backend-system/core-services/scheduler | service documentation} for more details. + * * @public */ export interface SchedulerService { diff --git a/packages/backend-plugin-api/src/services/definitions/TokenManagerService.ts b/packages/backend-plugin-api/src/services/definitions/TokenManagerService.ts index c9d7edcaef..52d5d1ff9f 100644 --- a/packages/backend-plugin-api/src/services/definitions/TokenManagerService.ts +++ b/packages/backend-plugin-api/src/services/definitions/TokenManagerService.ts @@ -15,9 +15,12 @@ */ /** - * Interface for creating and validating tokens. + * This is the legacy service for creating and validating tokens. Please migrate to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/token-manager | service documentation} for more details. * * @public + * @deprecated Please {@link https://backstage.io/docs/tutorials/auth-service-migration | migrate} to the new `coreServices.auth`, `coreServices.httpAuth`, and `coreServices.userInfo` services as needed instead. */ export interface TokenManagerService { /** diff --git a/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts b/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts index 734dcd9902..8cb40ff202 100644 --- a/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts +++ b/packages/backend-plugin-api/src/services/definitions/UrlReaderService.ts @@ -19,6 +19,8 @@ import { Readable } from 'stream'; /** * A generic interface for fetching plain data from URLs. * + * See the {@link https://backstage.io/docs/backend-system/core-services/url-reader | service documentation} for more details. + * * @public */ export interface UrlReaderService { diff --git a/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts index 90f5306a58..438de79219 100644 --- a/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts +++ b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts @@ -16,13 +16,27 @@ import { BackstageCredentials } from './AuthService'; -/** @public */ +/** + * Represents user information that is available to the backend, based on some + * user credentials. + * + * @public + */ export interface BackstageUserInfo { userEntityRef: string; ownershipEntityRefs: string[]; } -/** @public */ +/** + * Authenticated user information retrieval. + * + * See the {@link https://backstage.io/docs/backend-system/core-services/user-info | service documentation} for more details. + * + * @public + */ export interface UserInfoService { + /** + * Retrieve user information based on the provided credentials. + */ getUserInfo(credentials: BackstageCredentials): Promise; } diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index dfc3ea82cc..7da7355577 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -46,7 +46,6 @@ }, "dependencies": { "@backstage/backend-app-api": "workspace:^", - "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", @@ -65,9 +64,11 @@ "msw": "^1.0.0", "mysql2": "^3.0.0", "pg": "^8.11.3", + "pg-connection-string": "^2.3.0", "testcontainers": "^10.0.0", "textextensions": "^5.16.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/backend-test-utils/src/database/TestDatabases.test.ts b/packages/backend-test-utils/src/database/TestDatabases.test.ts index 64e4d76443..fea7189aec 100644 --- a/packages/backend-test-utils/src/database/TestDatabases.test.ts +++ b/packages/backend-test-utils/src/database/TestDatabases.test.ts @@ -14,28 +14,11 @@ * limitations under the License. */ -import knexFactory from 'knex'; -import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; -import { startMysqlContainer } from './startMysqlContainer'; -import { startPostgresContainer } from './startPostgresContainer'; import { TestDatabases } from './TestDatabases'; -const itIfDocker = isDockerDisabledForTests() ? it.skip : it; - jest.setTimeout(60_000); describe('TestDatabases', () => { - const OLD_ENV = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...OLD_ENV }; - }); - - afterAll(() => { - process.env = OLD_ENV; - }); - describe('each create', () => { const dbs = TestDatabases.create(); @@ -55,254 +38,4 @@ describe('TestDatabases', () => { }, ); }); - - describe('each connect', () => { - const dbs = TestDatabases.create(); - - itIfDocker( - 'obeys a provided connection string for postgres 16', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:16'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_16'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 15', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:15'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES15_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_15'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 14', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:14'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES14_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_14'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 13', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:13'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_13'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 12', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:12'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_12'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 11', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:11'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES11_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_11'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker( - 'obeys a provided connection string for postgres 9', - async () => { - const { host, port, user, password, stop } = - await startPostgresContainer('postgres:9'); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING = `postgresql://${user}:${password}@${host}:${port}`; - const input = await dbs.init('POSTGRES_9'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'pg', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }, - ); - - itIfDocker('obeys a provided connection string for mysql 8', async () => { - const { host, port, user, password, stop } = await startMysqlContainer( - 'mysql:8', - ); - - try { - // Leave a mark - process.env.BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING = `mysql://${user}:${password}@${host}:${port}/ignored`; - const input = await dbs.init('MYSQL_8'); - await input.schema.createTable('a', table => - table.string('x').primary(), - ); - await input.insert({ x: 'y' }).into('a'); - - // Look for the mark - const database = input.client.config.connection.database; - const output = knexFactory({ - client: 'mysql2', - connection: { host, port, user, password, database }, - }); - // eslint-disable-next-line jest/no-standalone-expect - await expect(output.select('x').from('a')).resolves.toEqual([ - { x: 'y' }, - ]); - } finally { - await stop(); - } - }); - }); }); diff --git a/packages/backend-test-utils/src/database/TestDatabases.ts b/packages/backend-test-utils/src/database/TestDatabases.ts index 4ea8fe05d7..cd20e2997b 100644 --- a/packages/backend-test-utils/src/database/TestDatabases.ts +++ b/packages/backend-test-utils/src/database/TestDatabases.ts @@ -14,27 +14,18 @@ * limitations under the License. */ -import { DatabaseManager, dropDatabase } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; -import { randomBytes } from 'crypto'; import { Knex } from 'knex'; import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; -import { startMysqlContainer } from './startMysqlContainer'; -import { startPostgresContainer } from './startPostgresContainer'; +import { MysqlEngine } from './mysql'; +import { PostgresEngine } from './postgres'; +import { SqliteEngine } from './sqlite'; import { - allDatabases, - Instance, + Engine, TestDatabaseId, TestDatabaseProperties, + allDatabases, } from './types'; -const LARGER_POOL_CONFIG = { - pool: { - min: 0, - max: 50, - }, -}; - /** * Encapsulates the creation of ephemeral test database instances for use * inside unit or integration tests. @@ -42,7 +33,17 @@ const LARGER_POOL_CONFIG = { * @public */ export class TestDatabases { - private readonly instanceById: Map; + private readonly engineFactoryByDriver: Record< + string, + (properties: TestDatabaseProperties) => Promise + > = { + pg: PostgresEngine.create, + mysql: MysqlEngine.create, + mysql2: MysqlEngine.create, + 'better-sqlite3': SqliteEngine.create, + sqlite3: SqliteEngine.create, + }; + private readonly engineByTestDatabaseId: Map; private readonly supportedIds: TestDatabaseId[]; private static defaultIds?: TestDatabaseId[]; @@ -114,7 +115,7 @@ export class TestDatabases { } private constructor(supportedIds: TestDatabaseId[]) { - this.instanceById = new Map(); + this.engineByTestDatabaseId = new Map(); this.supportedIds = supportedIds; } @@ -148,185 +149,29 @@ export class TestDatabases { ); } - let instance: Instance | undefined = this.instanceById.get(id); - - // Ensure that a testcontainers instance is up for this ID - if (!instance) { - instance = await this.initAny(properties); - this.instanceById.set(id, instance); - } - - // Ensure that a unique logical database is created in the instance - const databaseName = `db${randomBytes(16).toString('hex')}`; - const connection = await instance.databaseManager - .forPlugin(databaseName) - .getClient(); - - instance.connections.push(connection); - instance.databaseNames.push(databaseName); - - return connection; - } - - private async initAny(properties: TestDatabaseProperties): Promise { - // Use the connection string if provided - if (properties.driver === 'pg' || properties.driver === 'mysql2') { - const envVarName = properties.connectionStringEnvironmentVariableName; - if (envVarName) { - const connectionString = process.env[envVarName]; - if (connectionString) { - const config = new ConfigReader({ - backend: { - database: { - knexConfig: properties.driver.includes('sqlite') - ? {} - : LARGER_POOL_CONFIG, - client: properties.driver, - connection: connectionString, - }, - }, - }); - const databaseManager = DatabaseManager.fromConfig(config); - const databaseNames: Array = []; - return { - dropDatabases: async () => { - await dropDatabase( - config.getConfig('backend.database'), - ...databaseNames.map( - databaseName => `backstage_plugin_${databaseName}`, - ), - ); - }, - databaseManager, - databaseNames, - connections: [], - }; - } - } - } - - // Otherwise start a container for the purpose - switch (properties.driver) { - case 'pg': - return this.initPostgres(properties); - case 'mysql2': - return this.initMysql(properties); - case 'better-sqlite3': - case 'sqlite3': - return this.initSqlite(properties); - default: + let engine = this.engineByTestDatabaseId.get(id); + if (!engine) { + const factory = this.engineFactoryByDriver[properties.driver]; + if (!factory) { throw new Error(`Unknown database driver ${properties.driver}`); + } + engine = await factory(properties); + this.engineByTestDatabaseId.set(id, engine); } - } - private async initPostgres( - properties: TestDatabaseProperties, - ): Promise { - const { host, port, user, password, stop } = await startPostgresContainer( - properties.dockerImageName!, - ); - - const databaseManager = DatabaseManager.fromConfig( - new ConfigReader({ - backend: { - database: { - knexConfig: LARGER_POOL_CONFIG, - client: 'pg', - connection: { host, port, user, password }, - }, - }, - }), - ); - return { - stopContainer: stop, - databaseManager, - databaseNames: [], - connections: [], - }; - } - - private async initMysql( - properties: TestDatabaseProperties, - ): Promise { - const { host, port, user, password, stop } = await startMysqlContainer( - properties.dockerImageName!, - ); - - const databaseManager = DatabaseManager.fromConfig( - new ConfigReader({ - backend: { - database: { - knexConfig: LARGER_POOL_CONFIG, - client: 'mysql2', - connection: { host, port, user, password }, - }, - }, - }), - ); - - return { - stopContainer: stop, - databaseManager, - databaseNames: [], - connections: [], - }; - } - - private async initSqlite( - properties: TestDatabaseProperties, - ): Promise { - const databaseManager = DatabaseManager.fromConfig( - new ConfigReader({ - backend: { - database: { - client: properties.driver, - connection: ':memory:', - }, - }, - }), - ); - return { - databaseManager, - databaseNames: [], - connections: [], - }; + return await engine.createDatabaseInstance(); } private async shutdown() { - const instances = [...this.instanceById.values()]; - this.instanceById.clear(); + const engines = [...this.engineByTestDatabaseId.values()]; + this.engineByTestDatabaseId.clear(); - for (const { - stopContainer, - dropDatabases, - connections, - databaseManager, - } of instances) { - for (const connection of connections) { - try { - await connection.destroy(); - } catch (error) { - console.warn(`TestDatabases: Failed to destroy connection`, { - connection, - error, - }); - } - } - - // If the database is not running in docker then drop the databases + for (const engine of engines) { try { - await dropDatabases?.(); + await engine.shutdown(); } catch (error) { - console.warn(`TestDatabases: Failed to drop databases`, { - error, - }); - } - - try { - await stopContainer?.(); - } catch (error) { - console.warn(`TestDatabases: Failed to stop container`, { - databaseManager, + console.warn(`TestDatabases: Failed to shutdown engine`, { + engine, error, }); } diff --git a/packages/backend-test-utils/src/database/mysql.test.ts b/packages/backend-test-utils/src/database/mysql.test.ts new file mode 100644 index 0000000000..a5b6b854f3 --- /dev/null +++ b/packages/backend-test-utils/src/database/mysql.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knexFactory, { Knex } from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { MysqlEngine, startMysqlContainer } from './mysql'; +import { Engine, TestDatabaseId, allDatabases } from './types'; + +const itIfDocker = isDockerDisabledForTests() ? it.skip : it; +const ourDatabaseIds = Object.entries(allDatabases) + .filter(([, properties]) => properties.driver.includes('mysql')) + .map(([id]) => id as TestDatabaseId); + +jest.setTimeout(60_000); + +describe('startMysqlContainer', () => { + itIfDocker( + 'successfully launches the container and can stop it without problems', + async () => { + const { connection, stopContainer } = await startMysqlContainer( + 'mysql:8', + ); + const db = knexFactory({ client: 'mysql2', connection }); + try { + const result = await db.select(db.raw('version() AS version')); + // eslint-disable-next-line jest/no-standalone-expect + expect(result[0]?.version).toContain('8.'); + } finally { + await db.destroy(); + await stopContainer(); + } + }, + ); +}); + +describe('MysqlEngine', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + itIfDocker.each(ourDatabaseIds)( + 'uses given connection string, %p', + async testDatabaseId => { + const properties = allDatabases[testDatabaseId]; + const { connection } = await startMysqlContainer( + properties.dockerImageName!, + ); + + const outerKnex = knexFactory({ client: properties.driver, connection }); + const databases = await outerKnex + .raw('SHOW DATABASES') + .then(rows => rows[0].length); // account for meta databases, if any + + let knex: Knex | undefined; + let engine: Engine | undefined; + try { + process.env[ + properties.connectionStringEnvironmentVariableName! + ] = `mysql://${connection.user}:${connection.password}@${connection.host}:${connection.port}/ignored`; + engine = await MysqlEngine.create(properties); + knex = await engine.createDatabaseInstance(); + + // eslint-disable-next-line jest/no-standalone-expect + await expect( + outerKnex.raw('SHOW DATABASES').then(rows => rows[0].length), + ).resolves.toBe(databases + 1); + } finally { + await outerKnex.destroy(); + await knex?.destroy(); + await engine?.shutdown(); + } + }, + ); + + itIfDocker.each(ourDatabaseIds)( + 'creates docker containers, %p', + async testDatabaseId => { + const properties = allDatabases[testDatabaseId]; + delete process.env[properties.connectionStringEnvironmentVariableName!]; + const engine = await MysqlEngine.create(properties); + + try { + const knex = await engine.createDatabaseInstance(); + // eslint-disable-next-line jest/no-standalone-expect + await expect( + knex.select(knex.raw('version() as version')), + ).resolves.toEqual([{ version: expect.any(String) }]); + } finally { + await engine.shutdown(); + } + }, + ); +}); diff --git a/packages/backend-test-utils/src/database/mysql.ts b/packages/backend-test-utils/src/database/mysql.ts new file mode 100644 index 0000000000..ab130dc5c8 --- /dev/null +++ b/packages/backend-test-utils/src/database/mysql.ts @@ -0,0 +1,242 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stringifyError } from '@backstage/errors'; +import { randomBytes } from 'crypto'; +import knexFactory, { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import yn from 'yn'; +import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types'; + +async function waitForMysqlReady( + connection: Knex.MySqlConnectionConfig, +): Promise { + const startTime = Date.now(); + + let lastError: Error | undefined; + let attempts = 0; + for (;;) { + attempts += 1; + + let knex: Knex | undefined; + try { + knex = knexFactory({ + client: 'mysql2', + connection: { + // make a copy because the driver mutates this + ...connection, + }, + }); + const result = await knex.select(knex.raw('version() AS version')); + if (Array.isArray(result) && result[0]?.version) { + return; + } + } catch (e) { + lastError = e; + } finally { + await knex?.destroy(); + } + + if (Date.now() - startTime > 30_000) { + throw new Error( + `Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${ + lastError + ? `last error was ${stringifyError(lastError)}` + : '(no errors thrown)' + }`, + ); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +export async function startMysqlContainer(image: string): Promise<{ + connection: Knex.MySqlConnectionConfig; + stopContainer: () => Promise; +}> { + const user = 'root'; + const password = uuid(); + + // Lazy-load to avoid side-effect of importing testcontainers + const { GenericContainer } = await import('testcontainers'); + + const container = await new GenericContainer(image) + .withExposedPorts(3306) + .withEnvironment({ MYSQL_ROOT_PASSWORD: password }) + .withTmpFs({ '/var/lib/mysql': 'rw' }) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(3306); + const connection = { host, port, user, password }; + const stopContainer = async () => { + await container.stop({ timeout: 10_000 }); + }; + + await waitForMysqlReady(connection); + + return { connection, stopContainer }; +} + +export function parseMysqlConnectionString( + connectionString: string, +): Knex.MySqlConnectionConfig { + try { + const { + protocol, + username, + password, + port, + hostname, + pathname, + searchParams, + } = new URL(connectionString); + + if (protocol !== 'mysql:') { + throw new Error(`Unknown protocol ${protocol}`); + } else if (!username || !password) { + throw new Error(`Missing username/password`); + } else if (!pathname.match(/^\/[^/]+$/)) { + throw new Error(`Expected single path segment`); + } + + const result: Knex.MySqlConnectionConfig = { + user: username, + password, + host: hostname, + port: Number(port || 3306), + database: decodeURIComponent(pathname.substring(1)), + }; + + const ssl = searchParams.get('ssl'); + if (ssl) { + result.ssl = ssl; + } + + const debug = searchParams.get('debug'); + if (debug) { + result.debug = yn(debug); + } + + return result; + } catch (e) { + throw new Error(`Error while parsing MySQL connection string, ${e}`, e); + } +} + +export class MysqlEngine implements Engine { + static async create( + properties: TestDatabaseProperties, + ): Promise { + const { connectionStringEnvironmentVariableName, dockerImageName } = + properties; + + if (connectionStringEnvironmentVariableName) { + const connectionString = + process.env[connectionStringEnvironmentVariableName]; + if (connectionString) { + const connection = parseMysqlConnectionString(connectionString); + return new MysqlEngine( + properties, + connection as Knex.MySqlConnectionConfig, + ); + } + } + + if (dockerImageName) { + const { connection, stopContainer } = await startMysqlContainer( + dockerImageName, + ); + return new MysqlEngine(properties, connection, stopContainer); + } + + throw new Error(`Test databasee for ${properties.name} not configured`); + } + + readonly #properties: TestDatabaseProperties; + readonly #connection: Knex.MySqlConnectionConfig; + readonly #knexInstances: Knex[]; + readonly #databaseNames: string[]; + readonly #stopContainer?: () => Promise; + + constructor( + properties: TestDatabaseProperties, + connection: Knex.MySqlConnectionConfig, + stopContainer?: () => Promise, + ) { + this.#properties = properties; + this.#connection = connection; + this.#knexInstances = []; + this.#databaseNames = []; + this.#stopContainer = stopContainer; + } + + async createDatabaseInstance(): Promise { + const adminConnection = this.#connectAdmin(); + try { + const databaseName = `db${randomBytes(16).toString('hex')}`; + + await adminConnection.raw('CREATE DATABASE ??', [databaseName]); + this.#databaseNames.push(databaseName); + + const knexInstance = knexFactory({ + client: this.#properties.driver, + connection: { + ...this.#connection, + database: databaseName, + }, + ...LARGER_POOL_CONFIG, + }); + this.#knexInstances.push(knexInstance); + + return knexInstance; + } finally { + await adminConnection.destroy(); + } + } + + async shutdown(): Promise { + for (const instance of this.#knexInstances) { + await instance.destroy(); + } + + const adminConnection = this.#connectAdmin(); + try { + for (const databaseName of this.#databaseNames) { + await adminConnection.raw('DROP DATABASE ??', [databaseName]); + } + } finally { + await adminConnection.destroy(); + } + + await this.#stopContainer?.(); + } + + #connectAdmin(): Knex { + const connection = { + ...this.#connection, + database: null as unknown as string, + }; + return knexFactory({ + client: this.#properties.driver, + connection, + pool: { + acquireTimeoutMillis: 10000, + }, + }); + } +} diff --git a/packages/backend-test-utils/src/database/postgres.test.ts b/packages/backend-test-utils/src/database/postgres.test.ts new file mode 100644 index 0000000000..3d0d7b041b --- /dev/null +++ b/packages/backend-test-utils/src/database/postgres.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knexFactory, { Knex } from 'knex'; +import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; +import { startPostgresContainer, PostgresEngine } from './postgres'; +import { Engine, TestDatabaseId, allDatabases } from './types'; + +const itIfDocker = isDockerDisabledForTests() ? it.skip : it; +const ourDatabaseIds = Object.entries(allDatabases) + .filter(([, properties]) => properties.driver === 'pg') + .map(([id]) => id as TestDatabaseId); + +jest.setTimeout(60_000); + +describe('startPostgresContainer', () => { + itIfDocker( + 'successfully launches the container and can stop it without problems', + async () => { + const { connection, stopContainer } = await startPostgresContainer( + 'postgres:13', + ); + const db = knexFactory({ client: 'pg', connection }); + try { + const result = await db.select(db.raw('version()')); + // eslint-disable-next-line jest/no-standalone-expect + expect(result[0]?.version).toContain('PostgreSQL'); + } finally { + await db.destroy(); + await stopContainer(); + } + }, + ); +}); + +describe('PostgresEngine', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + itIfDocker.each(ourDatabaseIds)( + 'uses given connection string, %p', + async testDatabaseId => { + const properties = allDatabases[testDatabaseId]; + const { connection } = await startPostgresContainer( + properties.dockerImageName!, + ); + + const outerKnex = knexFactory({ client: properties.driver, connection }); + const databases = await outerKnex + .from('pg_database') + .then(rows => rows.length); // account for postgres, template0 etc + + let knex: Knex | undefined; + let engine: Engine | undefined; + try { + process.env[ + properties.connectionStringEnvironmentVariableName! + ] = `postgres://${connection.user}:${connection.password}@${connection.host}:${connection.port}`; + engine = await PostgresEngine.create(properties); + knex = await engine.createDatabaseInstance(); + + // eslint-disable-next-line jest/no-standalone-expect + await expect(outerKnex.from('pg_database')).resolves.toHaveLength( + databases + 1, + ); + } finally { + await outerKnex.destroy(); + await knex?.destroy(); + await engine?.shutdown(); + } + }, + ); + + itIfDocker.each(ourDatabaseIds)( + 'creates docker containers, %p', + async testDatabaseId => { + const properties = allDatabases[testDatabaseId]; + delete process.env[properties.connectionStringEnvironmentVariableName!]; + const engine = await PostgresEngine.create(properties); + + try { + const knex = await engine.createDatabaseInstance(); + // eslint-disable-next-line jest/no-standalone-expect + await expect(knex.select(knex.raw('version()'))).resolves.toEqual([ + { version: expect.stringContaining('PostgreSQL') }, + ]); + } finally { + await engine.shutdown(); + } + }, + ); +}); diff --git a/packages/backend-test-utils/src/database/postgres.ts b/packages/backend-test-utils/src/database/postgres.ts new file mode 100644 index 0000000000..c8a946d121 --- /dev/null +++ b/packages/backend-test-utils/src/database/postgres.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stringifyError } from '@backstage/errors'; +import { randomBytes } from 'crypto'; +import knexFactory, { Knex } from 'knex'; +import { parse as parsePgConnectionString } from 'pg-connection-string'; +import { v4 as uuid } from 'uuid'; +import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types'; + +async function waitForPostgresReady( + connection: Knex.PgConnectionConfig, +): Promise { + const startTime = Date.now(); + + let lastError: Error | undefined; + let attempts = 0; + for (;;) { + attempts += 1; + + let knex: Knex | undefined; + try { + knex = knexFactory({ + client: 'pg', + connection: { + // make a copy because the driver mutates this + ...connection, + }, + }); + const result = await knex.select(knex.raw('version()')); + if (Array.isArray(result) && result[0]?.version) { + return; + } + } catch (e) { + lastError = e; + } finally { + await knex?.destroy(); + } + + if (Date.now() - startTime > 30_000) { + throw new Error( + `Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${ + lastError + ? `last error was ${stringifyError(lastError)}` + : '(no errors thrown)' + }`, + ); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +export async function startPostgresContainer(image: string): Promise<{ + connection: Knex.PgConnectionConfig; + stopContainer: () => Promise; +}> { + const user = 'postgres'; + const password = uuid(); + + // Lazy-load to avoid side-effect of importing testcontainers + const { GenericContainer } = await import('testcontainers'); + + const container = await new GenericContainer(image) + .withExposedPorts(5432) + .withEnvironment({ POSTGRES_PASSWORD: password }) + .withTmpFs({ '/var/lib/postgresql/data': 'rw' }) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(5432); + const connection = { host, port, user, password }; + const stopContainer = async () => { + await container.stop({ timeout: 10_000 }); + }; + + await waitForPostgresReady(connection); + + return { connection, stopContainer }; +} + +export class PostgresEngine implements Engine { + static async create( + properties: TestDatabaseProperties, + ): Promise { + const { connectionStringEnvironmentVariableName, dockerImageName } = + properties; + + if (connectionStringEnvironmentVariableName) { + const connectionString = + process.env[connectionStringEnvironmentVariableName]; + if (connectionString) { + const connection = parsePgConnectionString(connectionString); + return new PostgresEngine( + properties, + connection as Knex.PgConnectionConfig, + ); + } + } + + if (dockerImageName) { + const { connection, stopContainer } = await startPostgresContainer( + dockerImageName, + ); + return new PostgresEngine(properties, connection, stopContainer); + } + + throw new Error(`Test databasee for ${properties.name} not configured`); + } + + readonly #properties: TestDatabaseProperties; + readonly #connection: Knex.PgConnectionConfig; + readonly #knexInstances: Knex[]; + readonly #databaseNames: string[]; + readonly #stopContainer?: () => Promise; + + constructor( + properties: TestDatabaseProperties, + connection: Knex.PgConnectionConfig, + stopContainer?: () => Promise, + ) { + this.#properties = properties; + this.#connection = connection; + this.#knexInstances = []; + this.#databaseNames = []; + this.#stopContainer = stopContainer; + } + + async createDatabaseInstance(): Promise { + const adminConnection = this.#connectAdmin(); + try { + const databaseName = `db${randomBytes(16).toString('hex')}`; + + await adminConnection.raw('CREATE DATABASE ??', [databaseName]); + this.#databaseNames.push(databaseName); + + const knexInstance = knexFactory({ + client: this.#properties.driver, + connection: { + ...this.#connection, + database: databaseName, + }, + ...LARGER_POOL_CONFIG, + }); + this.#knexInstances.push(knexInstance); + + return knexInstance; + } finally { + await adminConnection.destroy(); + } + } + + async shutdown(): Promise { + for (const instance of this.#knexInstances) { + await instance.destroy(); + } + + const adminConnection = this.#connectAdmin(); + try { + for (const databaseName of this.#databaseNames) { + await adminConnection.raw('DROP DATABASE ??', [databaseName]); + } + } finally { + await adminConnection.destroy(); + } + + await this.#stopContainer?.(); + } + + #connectAdmin(): Knex { + return knexFactory({ + client: this.#properties.driver, + connection: { + ...this.#connection, + database: 'postgres', + }, + pool: { + acquireTimeoutMillis: 10000, + }, + }); + } +} diff --git a/packages/backend-test-utils/src/database/sqlite.test.ts b/packages/backend-test-utils/src/database/sqlite.test.ts new file mode 100644 index 0000000000..8eca09a698 --- /dev/null +++ b/packages/backend-test-utils/src/database/sqlite.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2024 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 { SqliteEngine } from './sqlite'; +import { TestDatabaseId, allDatabases } from './types'; + +const ourDatabaseIds = Object.entries(allDatabases) + .filter(([, properties]) => properties.driver.includes('sqlite')) + .map(([id]) => id as TestDatabaseId); + +describe('SqliteEngine', () => { + it.each(ourDatabaseIds)( + 'should create a database instance, %p', + async testDatabaseId => { + for (let i = 0; i < 100; ++i) { + const properties = allDatabases[testDatabaseId]; + const engine = await SqliteEngine.create(properties); + + const instance1 = await engine.createDatabaseInstance(); + const instance2 = await engine.createDatabaseInstance(); + expect(instance1).toBeDefined(); + expect(instance2).toBeDefined(); + expect(instance1).not.toEqual(instance2); + + await instance1.schema.createTable('t', table => { + table.string('value'); + }); + await instance2.schema.createTable('t', table => { + table.string('value'); + }); + + await instance1('t').insert({ value: 'value1' }); + await instance2('t').insert({ value: 'value2' }); + + await expect(instance1('t')).resolves.toEqual([{ value: 'value1' }]); + await expect(instance2('t')).resolves.toEqual([{ value: 'value2' }]); + + await engine.shutdown(); + } + }, + ); +}); diff --git a/packages/backend-test-utils/src/database/sqlite.ts b/packages/backend-test-utils/src/database/sqlite.ts new file mode 100644 index 0000000000..92dc249225 --- /dev/null +++ b/packages/backend-test-utils/src/database/sqlite.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2024 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 knexFactory, { Knex } from 'knex'; +import { Engine, TestDatabaseProperties } from './types'; + +export class SqliteEngine implements Engine { + static async create( + properties: TestDatabaseProperties, + ): Promise { + return new SqliteEngine(properties); + } + + readonly #properties: TestDatabaseProperties; + readonly #instances: Knex[]; + + constructor(properties: TestDatabaseProperties) { + this.#properties = properties; + this.#instances = []; + } + + async createDatabaseInstance(): Promise { + const instance = knexFactory({ + client: this.#properties.driver, + connection: ':memory:', + useNullAsDefault: true, + }); + + instance.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + this.#instances.push(instance); + return instance; + } + + async shutdown(): Promise { + for (const instance of this.#instances) { + await instance.destroy(); + } + } +} diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.test.ts b/packages/backend-test-utils/src/database/startMysqlContainer.test.ts deleted file mode 100644 index 23cb128a35..0000000000 --- a/packages/backend-test-utils/src/database/startMysqlContainer.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import createConnection from 'knex'; -import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; -import { startMysqlContainer } from './startMysqlContainer'; - -const itIfDocker = isDockerDisabledForTests() ? it.skip : it; - -jest.setTimeout(60_000); - -describe('startMysqlContainer', () => { - itIfDocker('successfully launches the container', async () => { - const { stop, ...connection } = await startMysqlContainer('mysql:8'); - const db = createConnection({ client: 'mysql2', connection }); - try { - const result = await db.select(db.raw('version() AS version')); - // eslint-disable-next-line jest/no-standalone-expect - expect(result[0]?.version).toContain('8.'); - } finally { - await db.destroy(); - await stop(); - } - }); -}); diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.ts b/packages/backend-test-utils/src/database/startMysqlContainer.ts deleted file mode 100644 index 2b4c917393..0000000000 --- a/packages/backend-test-utils/src/database/startMysqlContainer.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import createConnection, { Knex } from 'knex'; -import { v4 as uuid } from 'uuid'; - -async function waitForMysqlReady( - connection: Knex.MySqlConnectionConfig, -): Promise { - const startTime = Date.now(); - const db = createConnection({ client: 'mysql2', connection }); - - try { - for (;;) { - try { - const result = await db.select(db.raw('version() AS version')); - if (result[0]?.version) { - return; - } - } catch (e) { - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for the database to be ready for connections, ${e}`, - ); - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - } finally { - db.destroy(); - } -} - -export async function startMysqlContainer(image: string) { - const user = 'root'; - const password = uuid(); - - // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); - - const container = await new GenericContainer(image) - .withExposedPorts(3306) - .withEnvironment({ MYSQL_ROOT_PASSWORD: password }) - .withTmpFs({ '/var/lib/mysql': 'rw' }) - .start(); - - const host = container.getHost(); - const port = container.getMappedPort(3306); - const stop = async () => { - await container.stop({ timeout: 10_000 }); - }; - - await waitForMysqlReady({ host, port, user, password }); - - return { host, port, user, password, stop }; -} diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.test.ts b/packages/backend-test-utils/src/database/startPostgresContainer.test.ts deleted file mode 100644 index f64a605fd4..0000000000 --- a/packages/backend-test-utils/src/database/startPostgresContainer.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import createConnection from 'knex'; -import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; -import { startPostgresContainer } from './startPostgresContainer'; - -const itIfDocker = isDockerDisabledForTests() ? it.skip : it; - -jest.setTimeout(60_000); - -describe('startPostgresContainer', () => { - itIfDocker('successfully launches the container', async () => { - const { stop, ...connection } = await startPostgresContainer('postgres:13'); - const db = createConnection({ client: 'pg', connection }); - try { - const result = await db.select(db.raw('version()')); - // eslint-disable-next-line jest/no-standalone-expect - expect(result[0]?.version).toContain('PostgreSQL'); - } finally { - await db.destroy(); - await stop(); - } - }); -}); diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.ts b/packages/backend-test-utils/src/database/startPostgresContainer.ts deleted file mode 100644 index 7a1c3f89c5..0000000000 --- a/packages/backend-test-utils/src/database/startPostgresContainer.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import createConnection, { Knex } from 'knex'; -import { v4 as uuid } from 'uuid'; - -async function waitForPostgresReady( - connection: Knex.PgConnectionConfig, -): Promise { - const startTime = Date.now(); - const db = createConnection({ client: 'pg', connection }); - - try { - for (;;) { - try { - const result = await db.select(db.raw('version()')); - if (Array.isArray(result) && result[0]?.version) { - return; - } - } catch (e) { - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for the database to be ready for connections, ${e}`, - ); - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - } finally { - db.destroy(); - } -} - -export async function startPostgresContainer(image: string) { - const user = 'postgres'; - const password = uuid(); - - // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); - - const container = await new GenericContainer(image) - .withExposedPorts(5432) - .withEnvironment({ POSTGRES_PASSWORD: password }) - .withTmpFs({ '/var/lib/postgresql/data': 'rw' }) - .start(); - - const host = container.getHost(); - const port = container.getMappedPort(5432); - const stop = async () => { - await container.stop({ timeout: 10_000 }); - }; - - await waitForPostgresReady({ host, port, user, password }); - - return { host, port, user, password, stop }; -} diff --git a/packages/backend-test-utils/src/database/types.ts b/packages/backend-test-utils/src/database/types.ts index 77a0c4e960..39a8af065c 100644 --- a/packages/backend-test-utils/src/database/types.ts +++ b/packages/backend-test-utils/src/database/types.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -import { DatabaseManager } from '@backstage/backend-common'; import { Knex } from 'knex'; import { getDockerImageForName } from '../util/getDockerImageForName'; +export interface Engine { + createDatabaseInstance(): Promise; + shutdown(): Promise; +} + /** * The possible databases to test against. * @@ -41,14 +45,6 @@ export type TestDatabaseProperties = { connectionStringEnvironmentVariableName?: string; }; -export type Instance = { - stopContainer?: () => Promise; - dropDatabases?: () => Promise; - databaseManager: DatabaseManager; - connections: Array; - databaseNames: Array; -}; - export const allDatabases: Record = Object.freeze({ POSTGRES_16: { @@ -112,3 +108,10 @@ export const allDatabases: Record = driver: 'better-sqlite3', }, }); + +export const LARGER_POOL_CONFIG = { + pool: { + min: 0, + max: 50, + }, +}; diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 7d2910d391..290c586f2c 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -25,7 +25,7 @@ import { PermissionsServiceRequestOptions } from '@backstage/backend-plugin-api' import { PolicyDecision } from '@backstage/plugin-permission-common'; import { QueryPermissionRequest } from '@backstage/plugin-permission-common'; import { ResourcePermission } from '@backstage/plugin-permission-common'; -import { TokenManager } from '@backstage/backend-common'; +import { TokenManagerService } from '@backstage/backend-plugin-api'; import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -289,7 +289,7 @@ export class ServerPermissionClient implements PermissionsService { config: Config, options: { discovery: DiscoveryService; - tokenManager: TokenManager; + tokenManager: TokenManagerService; auth?: AuthService; }, ): ServerPermissionClient; diff --git a/plugins/permission-node/src/ServerPermissionClient.test.ts b/plugins/permission-node/src/ServerPermissionClient.test.ts index 8273606845..6e99ff71ab 100644 --- a/plugins/permission-node/src/ServerPermissionClient.test.ts +++ b/plugins/permission-node/src/ServerPermissionClient.test.ts @@ -28,24 +28,13 @@ import { setupRequestMockHandlers, } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; -import { - PluginEndpointDiscovery, - ServerTokenManager, -} from '@backstage/backend-common'; import { setupServer } from 'msw/node'; import { RestContext, rest } from 'msw'; const server = setupServer(); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; -const discovery: PluginEndpointDiscovery = { - async getBaseUrl() { - return mockBaseUrl; - }, - async getExternalBaseUrl() { - return mockBaseUrl; - }, -}; + const testBasicPermission = createPermission({ name: 'test.permission', attributes: { @@ -63,21 +52,14 @@ const config = new ConfigReader({ permission: { enabled: true }, backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, }); -const logger = mockServices.logger.mock(); describe('ServerPermissionClient', () => { setupRequestMockHandlers(server); - it('should error if permissions are enabled but a no-op token manager is configured', async () => { - expect(() => - ServerPermissionClient.fromConfig(config, { - discovery, - tokenManager: ServerTokenManager.noop(), - }), - ).toThrow( - 'Service-to-service authentication must be configured before enabling permissions. Read more here https://backstage.io/docs/auth/service-to-service-auth', - ); - }); + const discovery = mockServices.discovery.mock(); + + discovery.getBaseUrl.mockResolvedValue(mockBaseUrl); + discovery.getExternalBaseUrl.mockResolvedValue(mockBaseUrl); describe('authorize', () => { let mockAuthorizeHandler: jest.Mock; @@ -100,7 +82,7 @@ describe('ServerPermissionClient', () => { it('should bypass the permission backend if permissions are disabled', async () => { const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { discovery, - tokenManager: ServerTokenManager.noop(), + tokenManager: mockServices.tokenManager.mock(), }); await client.authorize([ @@ -113,28 +95,28 @@ describe('ServerPermissionClient', () => { }); it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), + auth: mockServices.auth(), }); await client.authorize([{ permission: testBasicPermission }], { - token: (await tokenManager.getToken()).token, + token: mockCredentials.service.token(), }); expect(mockAuthorizeHandler).not.toHaveBeenCalled(); }); it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), + auth: mockServices.auth(), }); await client.authorize([{ permission: testBasicPermission }], { - token: 'a-user-token', + token: mockCredentials.user.token(), }); expect(mockAuthorizeHandler).toHaveBeenCalled(); @@ -162,7 +144,7 @@ describe('ServerPermissionClient', () => { it('should bypass the permission backend if permissions are disabled', async () => { const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { discovery, - tokenManager: ServerTokenManager.noop(), + tokenManager: mockServices.tokenManager.mock(), }); await client.authorizeConditional([ @@ -173,16 +155,15 @@ describe('ServerPermissionClient', () => { }); it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), }); await client.authorizeConditional( [{ permission: testResourcePermission }], { - token: (await tokenManager.getToken()).token, + token: mockCredentials.service.token(), }, ); @@ -190,16 +171,17 @@ describe('ServerPermissionClient', () => { }); it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const auth = mockServices.auth(); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), + auth, }); await client.authorizeConditional( [{ permission: testResourcePermission }], { - token: 'a-user-token', + token: mockCredentials.user.token(), }, ); @@ -229,7 +211,7 @@ describe('ServerPermissionClient', () => { it('should bypass the permission backend if permissions are disabled', async () => { const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { discovery, - tokenManager: ServerTokenManager.noop(), + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); @@ -246,10 +228,9 @@ describe('ServerPermissionClient', () => { }); it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); @@ -261,10 +242,9 @@ describe('ServerPermissionClient', () => { }); it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); @@ -305,7 +285,7 @@ describe('ServerPermissionClient', () => { it('should bypass the permission backend if permissions are disabled', async () => { const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { discovery, - tokenManager: ServerTokenManager.noop(), + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); @@ -320,10 +300,9 @@ describe('ServerPermissionClient', () => { }); it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); @@ -338,10 +317,9 @@ describe('ServerPermissionClient', () => { }); it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); const client = ServerPermissionClient.fromConfig(config, { discovery, - tokenManager, + tokenManager: mockServices.tokenManager.mock(), auth: mockServices.auth(), }); diff --git a/plugins/permission-node/src/ServerPermissionClient.ts b/plugins/permission-node/src/ServerPermissionClient.ts index cb39f310bd..cf54f3b894 100644 --- a/plugins/permission-node/src/ServerPermissionClient.ts +++ b/plugins/permission-node/src/ServerPermissionClient.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - TokenManager, - createLegacyAuthAdapters, -} from '@backstage/backend-common'; +import { createLegacyAuthAdapters } from '@backstage/backend-common'; import { AuthService, BackstageCredentials, @@ -25,6 +22,7 @@ import { DiscoveryService, PermissionsService, PermissionsServiceRequestOptions, + TokenManagerService, } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { @@ -53,7 +51,7 @@ export class ServerPermissionClient implements PermissionsService { config: Config, options: { discovery: DiscoveryService; - tokenManager: TokenManager; + tokenManager: TokenManagerService; auth?: AuthService; }, ) { diff --git a/plugins/proxy-backend/src/service/router.credentials.test.ts b/plugins/proxy-backend/src/service/router.credentials.test.ts index 0bfaa7f40a..54c865ba11 100644 --- a/plugins/proxy-backend/src/service/router.credentials.test.ts +++ b/plugins/proxy-backend/src/service/router.credentials.test.ts @@ -18,7 +18,11 @@ import { authServiceFactory, httpAuthServiceFactory, } from '@backstage/backend-app-api'; -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { + mockServices, + setupRequestMockHandlers, + startTestBackend, +} from '@backstage/backend-test-utils'; import { ResponseError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { HttpResponse, http, passthrough } from 'msw'; @@ -30,8 +34,7 @@ import fetch from 'node-fetch'; describe('credentials', () => { const worker = setupServer(); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterEach(() => worker.resetHandlers()); + setupRequestMockHandlers(worker); it('handles all valid credentials settings', async () => { const config = { diff --git a/yarn.lock b/yarn.lock index 2ad2995d52..66394ddf48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3574,7 +3574,6 @@ __metadata: resolution: "@backstage/backend-test-utils@workspace:packages/backend-test-utils" dependencies: "@backstage/backend-app-api": "workspace:^" - "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" @@ -3595,10 +3594,12 @@ __metadata: msw: ^1.0.0 mysql2: ^3.0.0 pg: ^8.11.3 + pg-connection-string: ^2.3.0 supertest: ^6.1.3 testcontainers: ^10.0.0 textextensions: ^5.16.0 uuid: ^9.0.0 + yn: ^4.0.0 peerDependencies: "@types/jest": "*" languageName: unknown @@ -33585,8 +33586,8 @@ __metadata: linkType: hard "mysql2@npm:^3.0.0": - version: 3.9.7 - resolution: "mysql2@npm:3.9.7" + version: 3.10.0 + resolution: "mysql2@npm:3.10.0" dependencies: denque: ^2.1.0 generate-function: ^2.3.1 @@ -33596,7 +33597,7 @@ __metadata: named-placeholders: ^1.1.3 seq-queue: ^0.0.5 sqlstring: ^2.3.2 - checksum: 535261d076f840f0966788b3f33a5ff7872e5da321240c2359be5c9e7ec19197ed5f6e01f0bc7beae06dd291d03eb2bde00f474461a578debcb85fcd98e347d3 + checksum: 4306de21317a05fcd4bbf28679e6b61b5e3421f7b8e2813ce7be8a06a8f7f4e09067bbd77507de465aba59a5742d1c48a3ed1038e96fb76935d24e1648d4b9c3 languageName: node linkType: hard