Merge branch 'backstage:master' into catelog-documentation-fix
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Marked the `TokenManagerService` and `IdentityService` types as deprecated
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Refactored `TestDatabases` to no longer depend on `backend-common`
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Deprecated `dropDatabase`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-node': patch
|
||||
---
|
||||
|
||||
Import `tokenManager` definition from `@backstage/backend-plugin-api`
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -312,7 +312,7 @@ export class DockerContainerRunner implements ContainerRunner {
|
||||
runContainer(options: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export function dropDatabase(
|
||||
dbConfig: Config,
|
||||
...databaseNames: string[]
|
||||
|
||||
@@ -38,7 +38,7 @@ export const databaseServiceFactory: () => ServiceFactory<
|
||||
'plugin'
|
||||
>;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export function dropDatabase(
|
||||
dbConfig: Config,
|
||||
...databaseNames: string[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BackstageCredentials>;
|
||||
// (undocumented)
|
||||
getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
// (undocumented)
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
>;
|
||||
// (undocumented)
|
||||
getPluginRequestToken(options: {
|
||||
onBehalfOf: BackstageCredentials;
|
||||
targetPluginId: string;
|
||||
}): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
// (undocumented)
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
|
||||
// (undocumented)
|
||||
listPublicServiceKeys(): Promise<{
|
||||
keys: JsonObject[];
|
||||
}>;
|
||||
@@ -116,14 +109,14 @@ export interface BackendPluginRegistrationPoints {
|
||||
}): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type BackstageCredentials<TPrincipal = unknown> = {
|
||||
$$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<T> = {
|
||||
// @public @deprecated (undocumented)
|
||||
export type ExtensionPointConfig = CreateExtensionPointOptions;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export interface HttpAuthService {
|
||||
// (undocumented)
|
||||
credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
req: Request_2<any, any, any, any, any>,
|
||||
options?: {
|
||||
@@ -344,7 +336,6 @@ export interface HttpAuthService {
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
// (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<AuthorizePermissionResponse[]>;
|
||||
// (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<TService, TScope extends 'root' | 'plugin'> {
|
||||
scope?: TScope;
|
||||
}
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export interface TokenManagerService {
|
||||
authenticate(token: string): Promise<void>;
|
||||
getToken(): Promise<{
|
||||
@@ -701,9 +687,8 @@ export interface UrlReaderService {
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export interface UserInfoService {
|
||||
// (undocumented)
|
||||
getUserInfo(credentials: BackstageCredentials): Promise<BackstageUserInfo>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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:<pluginId>' or 'external:<externalServiceId>'
|
||||
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:<pluginId>' or 'external:<externalServiceId>'
|
||||
|
||||
/**
|
||||
* 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<TPrincipal = unknown> = {
|
||||
$$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<BackstageCredentials>;
|
||||
|
||||
/**
|
||||
* Checks if the given credentials are of the given type, and narrows the
|
||||
* TypeScript type accordingly if there's a match.
|
||||
*/
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
|
||||
|
||||
/**
|
||||
* Create a credentials object that represents an unauthenticated caller.
|
||||
*/
|
||||
getNoneCredentials(): Promise<BackstageCredentials<BackstageNonePrincipal>>;
|
||||
|
||||
/**
|
||||
* Create a credentials object that represents the current service itself.
|
||||
*/
|
||||
getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<BackstageUserPrincipal>,
|
||||
): 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[];
|
||||
}>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
/**
|
||||
* An Express request object.
|
||||
*/
|
||||
req: Request<any, any, any, any, any>,
|
||||
/**
|
||||
* 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<TAllowed>;
|
||||
/**
|
||||
* 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<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
|
||||
|
||||
/**
|
||||
* 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 }>;
|
||||
|
||||
@@ -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/<plugin-id>`.
|
||||
*/
|
||||
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/<plugin-id>/static/*` paths, but not unauthenticated access.
|
||||
*/
|
||||
addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AuthorizePermissionResponse[]>;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<BackstageUserInfo>;
|
||||
}
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Instance>;
|
||||
private readonly engineFactoryByDriver: Record<
|
||||
string,
|
||||
(properties: TestDatabaseProperties) => Promise<Engine>
|
||||
> = {
|
||||
pg: PostgresEngine.create,
|
||||
mysql: MysqlEngine.create,
|
||||
mysql2: MysqlEngine.create,
|
||||
'better-sqlite3': SqliteEngine.create,
|
||||
sqlite3: SqliteEngine.create,
|
||||
};
|
||||
private readonly engineByTestDatabaseId: Map<string, Engine>;
|
||||
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<Instance> {
|
||||
// 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<string> = [];
|
||||
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<Instance> {
|
||||
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<Instance> {
|
||||
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<Instance> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void>;
|
||||
}> {
|
||||
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<MysqlEngine> {
|
||||
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<void>;
|
||||
|
||||
constructor(
|
||||
properties: TestDatabaseProperties,
|
||||
connection: Knex.MySqlConnectionConfig,
|
||||
stopContainer?: () => Promise<void>,
|
||||
) {
|
||||
this.#properties = properties;
|
||||
this.#connection = connection;
|
||||
this.#knexInstances = [];
|
||||
this.#databaseNames = [];
|
||||
this.#stopContainer = stopContainer;
|
||||
}
|
||||
|
||||
async createDatabaseInstance(): Promise<Knex> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void>;
|
||||
}> {
|
||||
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<PostgresEngine> {
|
||||
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<void>;
|
||||
|
||||
constructor(
|
||||
properties: TestDatabaseProperties,
|
||||
connection: Knex.PgConnectionConfig,
|
||||
stopContainer?: () => Promise<void>,
|
||||
) {
|
||||
this.#properties = properties;
|
||||
this.#connection = connection;
|
||||
this.#knexInstances = [];
|
||||
this.#databaseNames = [];
|
||||
this.#stopContainer = stopContainer;
|
||||
}
|
||||
|
||||
async createDatabaseInstance(): Promise<Knex> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<SqliteEngine> {
|
||||
return new SqliteEngine(properties);
|
||||
}
|
||||
|
||||
readonly #properties: TestDatabaseProperties;
|
||||
readonly #instances: Knex[];
|
||||
|
||||
constructor(properties: TestDatabaseProperties) {
|
||||
this.#properties = properties;
|
||||
this.#instances = [];
|
||||
}
|
||||
|
||||
async createDatabaseInstance(): Promise<Knex> {
|
||||
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<void> {
|
||||
for (const instance of this.#instances) {
|
||||
await instance.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Knex>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The possible databases to test against.
|
||||
*
|
||||
@@ -41,14 +45,6 @@ export type TestDatabaseProperties = {
|
||||
connectionStringEnvironmentVariableName?: string;
|
||||
};
|
||||
|
||||
export type Instance = {
|
||||
stopContainer?: () => Promise<void>;
|
||||
dropDatabases?: () => Promise<void>;
|
||||
databaseManager: DatabaseManager;
|
||||
connections: Array<Knex>;
|
||||
databaseNames: Array<string>;
|
||||
};
|
||||
|
||||
export const allDatabases: Record<TestDatabaseId, TestDatabaseProperties> =
|
||||
Object.freeze({
|
||||
POSTGRES_16: {
|
||||
@@ -112,3 +108,10 @@ export const allDatabases: Record<TestDatabaseId, TestDatabaseProperties> =
|
||||
driver: 'better-sqlite3',
|
||||
},
|
||||
});
|
||||
|
||||
export const LARGER_POOL_CONFIG = {
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 50,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user