Merge pull request #26282 from backstage/freben/nocommon
[NBS 1.0] Remove `backend-common` package
This commit is contained in:
@@ -50,7 +50,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
# @backstage/backend-common
|
||||
|
||||
> [!CAUTION]
|
||||
> This package is deprecated and will be removed in a near future, so please follow the deprecated instructions for the exports you still use.
|
||||
|
||||
Common functionality library for Backstage backends, implementing logging,
|
||||
error handling and similar.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the library to your backend package:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/backend add @backstage/backend-common
|
||||
```
|
||||
|
||||
then make use of the handlers and logger as necessary:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
errorHandler,
|
||||
getRootLogger,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
const app = express();
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/home', myHomeRouter);
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
getRootLogger().info(`Listening on port ${PORT}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://backstage.io/docs)
|
||||
@@ -1,32 +0,0 @@
|
||||
## API Report File for "@backstage/backend-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
// @alpha @deprecated
|
||||
export interface Context {
|
||||
readonly abortSignal: AbortSignal;
|
||||
readonly deadline: Date | undefined;
|
||||
value<T = unknown>(key: string): T | undefined;
|
||||
}
|
||||
|
||||
// @alpha @deprecated
|
||||
export class Contexts {
|
||||
static root(): Context;
|
||||
static withAbort(
|
||||
parentCtx: Context,
|
||||
source: AbortController | AbortSignal,
|
||||
): Context;
|
||||
static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context;
|
||||
static withTimeoutMillis(parentCtx: Context, timeout: number): Context;
|
||||
static withValue(
|
||||
parentCtx: Context,
|
||||
key: string,
|
||||
value: unknown | ((previous: unknown | undefined) => unknown),
|
||||
): Context;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
## API Report File for "@backstage/backend-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { overridePackagePathResolution as overridePackagePathResolution_2 } from '@backstage/backend-plugin-api/testUtils';
|
||||
import { OverridePackagePathResolutionOptions as OverridePackagePathResolutionOptions_2 } from '@backstage/backend-plugin-api/testUtils';
|
||||
import { PackagePathResolutionOverride as PackagePathResolutionOverride_2 } from '@backstage/backend-plugin-api/testUtils';
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const overridePackagePathResolution: typeof overridePackagePathResolution_2;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type OverridePackagePathResolutionOptions =
|
||||
OverridePackagePathResolutionOptions_2;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type PackagePathResolutionOverride = PackagePathResolutionOverride_2;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -1,561 +0,0 @@
|
||||
## API Report File for "@backstage/backend-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
/// <reference types="webpack-env" />
|
||||
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { AuthCallback } from 'isomorphic-git';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceOptions } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceSetOptions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
import cors from 'cors';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
import { DiscoveryService } from '@backstage/backend-plugin-api';
|
||||
import Docker from 'dockerode';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import { HttpAuthService } from '@backstage/backend-plugin-api';
|
||||
import { isChildPath as isChildPath_2 } from '@backstage/backend-plugin-api';
|
||||
import { isDatabaseConflictError as isDatabaseConflictError_2 } from '@backstage/backend-plugin-api';
|
||||
import { KubeConfig } from '@kubernetes/client-node';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { LoadConfigOptionsRemote } from '@backstage/config-loader';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { MergeResult } from 'isomorphic-git';
|
||||
import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { PluginMetadataService } from '@backstage/backend-plugin-api';
|
||||
import { PushResult } from 'isomorphic-git';
|
||||
import { ReadCommitResult } from 'isomorphic-git';
|
||||
import { Request as Request_2 } from 'express';
|
||||
import { RequestHandler } from 'express';
|
||||
import { resolvePackagePath as resolvePackagePath_2 } from '@backstage/backend-plugin-api';
|
||||
import { resolveSafeChildPath as resolveSafeChildPath_2 } from '@backstage/backend-plugin-api';
|
||||
import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { Router } from 'express';
|
||||
import { SchedulerService } from '@backstage/backend-plugin-api';
|
||||
import { Server } from 'http';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { TransportStreamOptions } from 'winston-transport';
|
||||
import { UrlReaderService } from '@backstage/backend-plugin-api';
|
||||
import { UserInfoService } from '@backstage/backend-plugin-api';
|
||||
import { V1PodTemplateSpec } from '@kubernetes/client-node';
|
||||
import * as winston from 'winston';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
// @public @deprecated
|
||||
export type AuthCallbackOptions = {
|
||||
onAuth: AuthCallback;
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClient = CacheService;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClientOptions = CacheServiceOptions;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClientSetOptions = CacheServiceSetOptions;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export class CacheManager {
|
||||
// (undocumented)
|
||||
forPlugin(pluginId: string): PluginCacheManager;
|
||||
static fromConfig(
|
||||
config: RootConfigService,
|
||||
options?: CacheManagerOptions,
|
||||
): CacheManager;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "CacheManagerOptions_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheManagerOptions = CacheManagerOptions_2;
|
||||
|
||||
// @public @deprecated
|
||||
export function cacheToPluginCacheManager(cache: CacheService): {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export const coloredFormat: winston.Logform.Format;
|
||||
|
||||
// @public @deprecated
|
||||
export interface ContainerRunner {
|
||||
runContainer(opts: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "createConfigSecretEnumerator_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export const createConfigSecretEnumerator: typeof createConfigSecretEnumerator_2;
|
||||
|
||||
// @public @deprecated
|
||||
export function createLegacyAuthAdapters<
|
||||
TOptions extends {
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
userInfo?: UserInfoService;
|
||||
identity?: LegacyIdentityService;
|
||||
tokenManager?: TokenManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
},
|
||||
TAdapters = (TOptions extends {
|
||||
auth?: AuthService;
|
||||
}
|
||||
? {
|
||||
auth: AuthService;
|
||||
}
|
||||
: {}) &
|
||||
(TOptions extends {
|
||||
httpAuth?: HttpAuthService;
|
||||
}
|
||||
? {
|
||||
httpAuth: HttpAuthService;
|
||||
}
|
||||
: {}) &
|
||||
(TOptions extends {
|
||||
userInfo?: UserInfoService;
|
||||
}
|
||||
? {
|
||||
userInfo: UserInfoService;
|
||||
}
|
||||
: {}),
|
||||
>(options: TOptions): TAdapters;
|
||||
|
||||
// @public @deprecated
|
||||
export function createRootLogger(
|
||||
options?: winston.LoggerOptions,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): winston.Logger;
|
||||
|
||||
// @public @deprecated
|
||||
export function createServiceBuilder(_module: NodeModule): ServiceBuilder;
|
||||
|
||||
// @public @deprecated
|
||||
export function createStatusCheckRouter(options: {
|
||||
logger: LoggerService;
|
||||
path?: string;
|
||||
statusCheck?: StatusCheck;
|
||||
}): Promise<express.Router>;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
// (undocumented)
|
||||
forPlugin(
|
||||
pluginId: string,
|
||||
deps?:
|
||||
| {
|
||||
lifecycle: LifecycleService;
|
||||
pluginMetadata: PluginMetadataService;
|
||||
}
|
||||
| undefined,
|
||||
): PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): DatabaseManager;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "DatabaseManagerOptions_2" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export type DatabaseManagerOptions = DatabaseManagerOptions_2;
|
||||
|
||||
// @public @deprecated
|
||||
export class DockerContainerRunner implements ContainerRunner {
|
||||
constructor(options: { dockerClient: Docker });
|
||||
// (undocumented)
|
||||
runContainer(options: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function errorHandler(
|
||||
options?: ErrorHandlerOptions,
|
||||
): ErrorRequestHandler;
|
||||
|
||||
// @public @deprecated
|
||||
export type ErrorHandlerOptions = {
|
||||
showStackTraces?: boolean;
|
||||
logger?: LoggerService;
|
||||
logClientErrors?: boolean;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export function getRootLogger(): winston.Logger;
|
||||
|
||||
// @public @deprecated
|
||||
export function getVoidLogger(): winston.Logger;
|
||||
|
||||
// @public @deprecated
|
||||
export class Git {
|
||||
// (undocumented)
|
||||
add(options: { dir: string; filepath: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
addRemote(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
url: string;
|
||||
force?: boolean;
|
||||
}): Promise<void>;
|
||||
// (undocumented)
|
||||
branch(options: { dir: string; ref: string }): Promise<void>;
|
||||
// (undocumented)
|
||||
checkout(options: { dir: string; ref: string }): Promise<void>;
|
||||
clone(options: {
|
||||
url: string;
|
||||
dir: string;
|
||||
ref?: string;
|
||||
depth?: number;
|
||||
noCheckout?: boolean;
|
||||
}): Promise<void>;
|
||||
// (undocumented)
|
||||
commit(options: {
|
||||
dir: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
committer: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}): Promise<string>;
|
||||
currentBranch(options: {
|
||||
dir: string;
|
||||
fullName?: boolean;
|
||||
}): Promise<string | undefined>;
|
||||
// (undocumented)
|
||||
deleteRemote(options: { dir: string; remote: string }): Promise<void>;
|
||||
fetch(options: {
|
||||
dir: string;
|
||||
remote?: string;
|
||||
tags?: boolean;
|
||||
}): Promise<void>;
|
||||
// (undocumented)
|
||||
static fromAuth: (options: StaticAuthOptions | AuthCallbackOptions) => Git;
|
||||
// (undocumented)
|
||||
init(options: { dir: string; defaultBranch?: string }): Promise<void>;
|
||||
log(options: { dir: string; ref?: string }): Promise<ReadCommitResult[]>;
|
||||
merge(options: {
|
||||
dir: string;
|
||||
theirs: string;
|
||||
ours?: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
committer: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}): Promise<MergeResult>;
|
||||
// (undocumented)
|
||||
push(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
remoteRef?: string;
|
||||
force?: boolean;
|
||||
}): Promise<PushResult>;
|
||||
readCommit(options: { dir: string; sha: string }): Promise<ReadCommitResult>;
|
||||
remove(options: { dir: string; filepath: string }): Promise<void>;
|
||||
resolveRef(options: { dir: string; ref: string }): Promise<string>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
class HostDiscovery implements DiscoveryService {
|
||||
static fromConfig(config: Config): HostDiscovery;
|
||||
// (undocumented)
|
||||
getBaseUrl(pluginId: string): Promise<string>;
|
||||
// (undocumented)
|
||||
getExternalBaseUrl(pluginId: string): Promise<string>;
|
||||
}
|
||||
export { HostDiscovery };
|
||||
export { HostDiscovery as SingleHostDiscovery };
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const isChildPath: typeof isChildPath_2;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const isDatabaseConflictError: typeof isDatabaseConflictError_2;
|
||||
|
||||
// @public @deprecated
|
||||
export class KubernetesContainerRunner implements ContainerRunner {
|
||||
constructor(options: KubernetesContainerRunnerOptions);
|
||||
// (undocumented)
|
||||
runContainer(options: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export type KubernetesContainerRunnerMountBase = {
|
||||
volumeName: string;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type KubernetesContainerRunnerOptions = {
|
||||
kubeConfig: KubeConfig;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
mountBase?: KubernetesContainerRunnerMountBase;
|
||||
podTemplate?: V1PodTemplateSpec;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type LegacyCreateRouter<TEnv> = (deps: TEnv) => Promise<RequestHandler>;
|
||||
|
||||
// @public @deprecated
|
||||
export interface LegacyIdentityService {
|
||||
// (undocumented)
|
||||
getIdentity(options: { request: Request_2<unknown> }): Promise<
|
||||
| {
|
||||
expiresInSeconds?: number;
|
||||
token: string;
|
||||
identity: {
|
||||
type: 'user';
|
||||
userEntityRef: string;
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export const legacyPlugin: (
|
||||
name: string,
|
||||
createRouterImport: Promise<{
|
||||
default: LegacyCreateRouter<
|
||||
TransformedEnv<
|
||||
{
|
||||
cache: CacheService;
|
||||
config: RootConfigService;
|
||||
database: DatabaseService;
|
||||
discovery: DiscoveryService;
|
||||
logger: LoggerService;
|
||||
permissions: PermissionsService;
|
||||
scheduler: SchedulerService;
|
||||
reader: UrlReaderService;
|
||||
},
|
||||
{
|
||||
logger: (log: LoggerService) => Logger;
|
||||
cache: (cache: CacheService) => {
|
||||
getClient(options?: CacheServiceOptions | undefined): CacheService;
|
||||
};
|
||||
}
|
||||
> & {
|
||||
tokenManager: TokenManager;
|
||||
identity: LegacyIdentityService;
|
||||
}
|
||||
>;
|
||||
}>,
|
||||
) => BackendFeature;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export function loadBackendConfig(options: {
|
||||
logger: LoggerService;
|
||||
remote?: LoadConfigOptionsRemote;
|
||||
additionalConfigs?: AppConfig[];
|
||||
argv: string[];
|
||||
watch?: boolean;
|
||||
}): Promise<Config>;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export function loggerToWinstonLogger(
|
||||
logger: LoggerService,
|
||||
opts?: TransportStreamOptions,
|
||||
): Logger;
|
||||
|
||||
// @public @deprecated
|
||||
export function makeLegacyPlugin<
|
||||
TEnv extends Record<string, unknown>,
|
||||
TEnvTransforms extends {
|
||||
[key in keyof TEnv]?: (dep: TEnv[key]) => unknown;
|
||||
},
|
||||
>(
|
||||
envMapping: {
|
||||
[key in keyof TEnv]: ServiceRef<TEnv[key]>;
|
||||
},
|
||||
envTransforms: TEnvTransforms,
|
||||
): (
|
||||
name: string,
|
||||
createRouterImport: Promise<{
|
||||
default: LegacyCreateRouter<
|
||||
TransformedEnv<TEnv, TEnvTransforms> & {
|
||||
tokenManager: TokenManager;
|
||||
identity: LegacyIdentityService;
|
||||
}
|
||||
>;
|
||||
}>,
|
||||
) => BackendFeature;
|
||||
|
||||
// @public @deprecated
|
||||
export function notFoundHandler(): RequestHandler;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginCacheManager = {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginDatabaseManager = DatabaseService;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginEndpointDiscovery = DiscoveryService;
|
||||
|
||||
// @public @deprecated
|
||||
export interface PullOptions {
|
||||
// (undocumented)
|
||||
[key: string]: unknown;
|
||||
// (undocumented)
|
||||
authconfig?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
email?: string;
|
||||
serveraddress?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function redactWinstonLogLine(
|
||||
info: winston.Logform.TransformableInfo,
|
||||
): winston.Logform.TransformableInfo;
|
||||
|
||||
// @public @deprecated
|
||||
export function requestLoggingHandler(logger?: LoggerService): RequestHandler;
|
||||
|
||||
// @public @deprecated
|
||||
export type RequestLoggingHandlerFactory = (
|
||||
logger?: LoggerService,
|
||||
) => RequestHandler;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const resolvePackagePath: typeof resolvePackagePath_2;
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const resolveSafeChildPath: typeof resolveSafeChildPath_2;
|
||||
|
||||
// @public @deprecated
|
||||
export type RunContainerOptions = {
|
||||
imageName: string;
|
||||
command?: string | string[];
|
||||
args: string[];
|
||||
logStream?: Writable;
|
||||
mountDirs?: Record<string, string>;
|
||||
workingDir?: string;
|
||||
envVars?: Record<string, string>;
|
||||
pullImage?: boolean;
|
||||
defaultUser?: boolean;
|
||||
pullOptions?: PullOptions;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export class ServerTokenManager implements TokenManager {
|
||||
// (undocumented)
|
||||
authenticate(token: string): Promise<void>;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: ServerTokenManagerOptions,
|
||||
): TokenManager;
|
||||
// (undocumented)
|
||||
getToken(): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
static noop(): TokenManager;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export interface ServerTokenManagerOptions {
|
||||
allowDisabledTokenManager?: boolean;
|
||||
logger: LoggerService;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export type ServiceBuilder = {
|
||||
loadConfig(config: Config): ServiceBuilder;
|
||||
setPort(port: number): ServiceBuilder;
|
||||
setHost(host: string): ServiceBuilder;
|
||||
setLogger(logger: LoggerService): ServiceBuilder;
|
||||
enableCors(options: cors.CorsOptions): ServiceBuilder;
|
||||
setHttpsSettings(settings: {
|
||||
certificate:
|
||||
| {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
| {
|
||||
hostname: string;
|
||||
};
|
||||
}): ServiceBuilder;
|
||||
addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
|
||||
setRequestLoggingHandler(
|
||||
requestLoggingHandler: RequestLoggingHandlerFactory,
|
||||
): ServiceBuilder;
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
|
||||
disableDefaultErrorHandler(): ServiceBuilder;
|
||||
start(): Promise<Server>;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export function setRootLogger(newLogger: winston.Logger): void;
|
||||
|
||||
// @public @deprecated
|
||||
export type StaticAuthOptions = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type StatusCheck = () => Promise<any>;
|
||||
|
||||
// @public @deprecated
|
||||
export function statusCheckHandler(
|
||||
options?: StatusCheckHandlerOptions,
|
||||
): Promise<RequestHandler>;
|
||||
|
||||
// @public @deprecated
|
||||
export interface StatusCheckHandlerOptions {
|
||||
statusCheck?: StatusCheck;
|
||||
}
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export interface TokenManager {
|
||||
authenticate(token: string): Promise<void>;
|
||||
getToken(): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public @deprecated
|
||||
export function useHotCleanup(
|
||||
_module: NodeModule,
|
||||
cancelEffect: () => void,
|
||||
): void;
|
||||
|
||||
// @public @deprecated
|
||||
export function useHotMemoize<T>(_module: NodeModule, valueFactory: () => T): T;
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-backend-common
|
||||
title: '@backstage/backend-common'
|
||||
description: Common functionality library for Backstage backends
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-node-library
|
||||
owner: maintainers
|
||||
Vendored
-265
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
export interface Config {
|
||||
app: {
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
};
|
||||
|
||||
backend: {
|
||||
/** Backend configuration for when request authentication is enabled */
|
||||
auth?: {
|
||||
/** Keys shared by all backends for signing and validating backend tokens. */
|
||||
keys?: {
|
||||
/**
|
||||
* Secret for generating tokens. Should be a base64 string, recommended
|
||||
* length is 24 bytes.
|
||||
*
|
||||
* @visibility secret
|
||||
*/
|
||||
secret: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
baseUrl: string; // defined in core, but repeated here without doc
|
||||
|
||||
/** Address that the backend should listen to. */
|
||||
listen:
|
||||
| string
|
||||
| {
|
||||
/** Address of the interface that the backend should bind to. */
|
||||
host?: string;
|
||||
/** Port that the backend should listen to. */
|
||||
port?: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTPS configuration for the backend. If omitted the backend will serve HTTP.
|
||||
*
|
||||
* Setting this to `true` will cause self-signed certificates to be generated, which
|
||||
* can be useful for local development or other non-production scenarios.
|
||||
*/
|
||||
https?:
|
||||
| true
|
||||
| {
|
||||
/** Certificate configuration */
|
||||
certificate?: {
|
||||
/** PEM encoded certificate. Use $file to load in a file */
|
||||
cert: string;
|
||||
/**
|
||||
* PEM encoded certificate key. Use $file to load in a file.
|
||||
* @visibility secret
|
||||
*/
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An absolute path to a directory that can be used as a working dir, for
|
||||
* example as scratch space for large operations.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Note that this must be an absolute path.
|
||||
*
|
||||
* If not set, the operating system's designated temporary directory is
|
||||
* commonly used, but that is implementation defined per plugin.
|
||||
*
|
||||
* Plugins are encouraged to heed this config setting if present, to allow
|
||||
* deployment in severely locked-down or limited environments.
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
|
||||
/** Database connection configuration, select base database type using the `client` field */
|
||||
database: {
|
||||
/** Default database client to use */
|
||||
client: 'better-sqlite3' | 'sqlite3' | 'pg';
|
||||
/**
|
||||
* Base database connection string, or object with individual connection properties
|
||||
* @visibility secret
|
||||
*/
|
||||
connection:
|
||||
| string
|
||||
| {
|
||||
/**
|
||||
* Password that belongs to the client User
|
||||
* @visibility secret
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* Other connection settings
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Database name prefix override */
|
||||
prefix?: string;
|
||||
/**
|
||||
* Whether to ensure the given database exists by creating it if it does not.
|
||||
* Defaults to true if unspecified.
|
||||
*/
|
||||
ensureExists?: boolean;
|
||||
/**
|
||||
* Whether to ensure the given database schema exists by creating it if it does not.
|
||||
* Defaults to false if unspecified.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client when pluginDivisionMode: schema
|
||||
*/
|
||||
ensureSchemaExists?: boolean;
|
||||
/**
|
||||
* How plugins databases are managed/divided in the provided database instance.
|
||||
*
|
||||
* `database` -> Plugins are each given their own database to manage their schemas/tables.
|
||||
*
|
||||
* `schema` -> Plugins will be given their own schema (in the specified/default database)
|
||||
* to manage their tables.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client.
|
||||
*
|
||||
* @default database
|
||||
*/
|
||||
pluginDivisionMode?: 'database' | 'schema';
|
||||
/** Configures the ownership of newly created schemas in pg databases. */
|
||||
role?: string;
|
||||
/**
|
||||
* Arbitrary config object to pass to knex when initializing
|
||||
* (https://knexjs.org/#Installation-client). Most notable is the debug
|
||||
* and asyncStackTraces booleans
|
||||
*/
|
||||
knexConfig?: object;
|
||||
/** Plugin specific database configuration and client override */
|
||||
plugin?: {
|
||||
[pluginId: string]: {
|
||||
/** Database client override */
|
||||
client?: 'better-sqlite3' | 'sqlite3' | 'pg';
|
||||
/**
|
||||
* Database connection string or Knex object override
|
||||
* @visibility secret
|
||||
*/
|
||||
connection?: string | object;
|
||||
/**
|
||||
* Whether to ensure the given database exists by creating it if it does not.
|
||||
* Defaults to base config if unspecified.
|
||||
*/
|
||||
ensureExists?: boolean;
|
||||
/**
|
||||
* Whether to ensure the given database schema exists by creating it if it does not.
|
||||
* Defaults to false if unspecified.
|
||||
*
|
||||
* NOTE: Currently only supported by the `pg` client when pluginDivisionMode: schema
|
||||
*/
|
||||
ensureSchemaExists?: boolean;
|
||||
/**
|
||||
* Arbitrary config object to pass to knex when initializing
|
||||
* (https://knexjs.org/#Installation-client). Most notable is the
|
||||
* debug and asyncStackTraces booleans.
|
||||
*
|
||||
* This is merged recursively into the base knexConfig
|
||||
*/
|
||||
knexConfig?: object;
|
||||
/** Configures the ownership of newly created schemas in pg databases. */
|
||||
role?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Cache connection configuration, select cache type using the `store` field */
|
||||
cache?:
|
||||
| {
|
||||
store: 'memory';
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
}
|
||||
| {
|
||||
store: 'redis';
|
||||
/**
|
||||
* A redis connection string in the form `redis://user:pass@host:port`.
|
||||
* @visibility secret
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
/**
|
||||
* Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache.
|
||||
* Defaults to true if unspecified.
|
||||
*/
|
||||
useRedisSets?: boolean;
|
||||
}
|
||||
| {
|
||||
store: 'memcache';
|
||||
/**
|
||||
* A memcache connection string in the form `user:pass@host:port`.
|
||||
* @visibility secret
|
||||
*/
|
||||
connection: string;
|
||||
/** An optional default TTL (in milliseconds). */
|
||||
defaultTtl?: number | HumanDuration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Properties returned upon CORS requests to the backend, including the app-backend.
|
||||
*/
|
||||
cors?: {
|
||||
origin?: string | string[];
|
||||
methods?: string | string[];
|
||||
allowedHeaders?: string | string[];
|
||||
exposedHeaders?: string | string[];
|
||||
credentials?: boolean;
|
||||
maxAge?: number;
|
||||
preflightContinue?: boolean;
|
||||
optionsSuccessStatus?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content Security Policy options.
|
||||
*
|
||||
* The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The
|
||||
* values are on the format that the helmet library expects them, as an
|
||||
* array of strings. There is also the special value false, which means to
|
||||
* remove the default value that Backstage puts in place for that policy.
|
||||
*/
|
||||
csp?: { [policyId: string]: string[] | false };
|
||||
|
||||
/**
|
||||
* Configuration related to URL reading, used for example for reading catalog info
|
||||
* files, scaffolder templates, and techdocs content.
|
||||
*/
|
||||
reading?: {
|
||||
/**
|
||||
* A list of targets to allow outgoing requests to. Users will be able to make
|
||||
* requests on behalf of the backend to the targets that are allowed by this list.
|
||||
*/
|
||||
allow?: Array<{
|
||||
/**
|
||||
* A host to allow outgoing requests to, being either a full host or
|
||||
* a subdomain wildcard pattern with a leading `*`. For example `example.com`
|
||||
* and `*.example.com` are valid values, `prod.*.example.com` is not.
|
||||
* The host may also contain a port, for example `example.com:8080`.
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* An optional list of paths. In case they are present only targets matching
|
||||
* any of them will are allowed. You can use trailing slashes to make sure only
|
||||
* subdirectories are allowed, for example `/mydir/` will allow targets with
|
||||
* paths like `/mydir/a` but will block paths like `/mydir2`.
|
||||
*/
|
||||
paths?: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Knip report
|
||||
|
||||
## Unused dependencies (3)
|
||||
|
||||
| Name | Location | Severity |
|
||||
| :-------------------- | :----------- | :------- |
|
||||
| @manypkg/get-packages | package.json | error |
|
||||
| @types/webpack-env | package.json | error |
|
||||
| mysql2 | package.json | error |
|
||||
|
||||
## Unused devDependencies (3)
|
||||
|
||||
| Name | Location | Severity |
|
||||
| :----------------- | :----------- | :------- |
|
||||
| @types/webpack-env | package.json | error |
|
||||
| better-sqlite3 | package.json | error |
|
||||
| mysql2 | package.json | error |
|
||||
|
||||
## Referenced optional peerDependencies (1)
|
||||
|
||||
| Name | Location | Severity |
|
||||
| :------------------- | :----------- | :------- |
|
||||
| pg-connection-string | package.json | error |
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"version": "0.25.0",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/backend-common"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./alpha": "./src/alpha.ts",
|
||||
"./testUtils": "./src/testUtils.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"alpha": [
|
||||
"src/alpha.ts"
|
||||
],
|
||||
"testUtils": [
|
||||
"src/testUtils.ts"
|
||||
],
|
||||
"package.json": [
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test",
|
||||
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/abort-controller": "^3.347.0",
|
||||
"@aws-sdk/client-codecommit": "^3.350.0",
|
||||
"@aws-sdk/client-s3": "^3.350.0",
|
||||
"@aws-sdk/credential-providers": "^3.350.0",
|
||||
"@aws-sdk/types": "^3.347.0",
|
||||
"@backstage/backend-dev-utils": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/config-loader": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/integration": "workspace:^",
|
||||
"@backstage/integration-aws-node": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@google-cloud/storage": "^7.0.0",
|
||||
"@keyv/memcache": "^1.3.5",
|
||||
"@keyv/redis": "^2.5.3",
|
||||
"@kubernetes/client-node": "0.20.0",
|
||||
"@manypkg/get-packages": "^1.1.3",
|
||||
"@octokit/rest": "^19.0.3",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/dockerode": "^3.3.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/luxon": "^3.0.0",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"archiver": "^7.0.0",
|
||||
"base64-stream": "^1.0.0",
|
||||
"compression": "^1.7.4",
|
||||
"concat-stream": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^4.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"git-url-parse": "^14.0.0",
|
||||
"helmet": "^6.0.0",
|
||||
"isomorphic-git": "^1.23.0",
|
||||
"jose": "^5.0.0",
|
||||
"keyv": "^4.5.2",
|
||||
"knex": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"logform": "^2.3.2",
|
||||
"luxon": "^3.0.0",
|
||||
"minimatch": "^9.0.0",
|
||||
"minimist": "^1.2.5",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^3.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"p-limit": "^3.1.0",
|
||||
"path-to-regexp": "^8.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"raw-body": "^2.4.1",
|
||||
"selfsigned": "^2.0.0",
|
||||
"stoppable": "^1.1.0",
|
||||
"tar": "^6.1.12",
|
||||
"triple-beam": "^1.4.1",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"winston-transport": "^4.5.0",
|
||||
"yauzl": "^3.0.0",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/util-stream-node": "^3.350.0",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/base64-stream": "^1.0.2",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/http-errors": "^2.0.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"@types/pg": "^8.6.6",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/tar": "^6.1.1",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"aws-sdk-client-mock": "^4.0.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"msw": "^1.0.0",
|
||||
"mysql2": "^3.0.0",
|
||||
"supertest": "^7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-connection-string": "^2.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-connection-string": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"configSchema": "config.d.ts",
|
||||
"deprecated": "This package is deprecated, please follow the deprecation instructions for the exports you still use"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './deprecated/context';
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* 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 { createLegacyAuthAdapters } from './createLegacyAuthAdapters';
|
||||
import { Request } from 'express';
|
||||
import { TokenManager } from '../../deprecated';
|
||||
|
||||
const mockTokenManager: TokenManager = {
|
||||
async getToken(): Promise<{ token: string }> {
|
||||
return { token: 'mock-token' };
|
||||
},
|
||||
async authenticate(token: string): Promise<void> {
|
||||
if (token !== 'mock-token') {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
describe('createLegacyAuthAdapters', () => {
|
||||
it('should pass through auth if only auth is provided', () => {
|
||||
const auth = {};
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: auth as any,
|
||||
tokenManager: mockTokenManager,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
expect(ret.auth).toBe(auth);
|
||||
});
|
||||
|
||||
it('should pass through httpAuth if only httpAuth is provided', () => {
|
||||
const httpAuth = {};
|
||||
const ret = createLegacyAuthAdapters({
|
||||
httpAuth: httpAuth as any,
|
||||
tokenManager: mockTokenManager,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
expect(ret.httpAuth).toBe(httpAuth);
|
||||
});
|
||||
|
||||
it('should pass through both auth and httpAuth if both are provided', () => {
|
||||
const auth = {};
|
||||
const httpAuth = {};
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: auth as any,
|
||||
httpAuth: httpAuth as any,
|
||||
tokenManager: mockTokenManager,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
expect(ret.auth).toBe(auth);
|
||||
expect(ret.httpAuth).toBe(httpAuth);
|
||||
});
|
||||
|
||||
it('should pass through userInfo if it is provided', () => {
|
||||
const auth = {};
|
||||
const userInfo = {};
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: auth as any,
|
||||
userInfo: userInfo as any,
|
||||
tokenManager: mockTokenManager,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
expect(ret.auth).toBe(auth);
|
||||
expect(ret.userInfo).toBe(userInfo);
|
||||
});
|
||||
|
||||
it('should adapt all services if none are provided', () => {
|
||||
const ret = createLegacyAuthAdapters({
|
||||
auth: undefined,
|
||||
httpAuth: undefined,
|
||||
tokenManager: mockTokenManager,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
expect(ret).toEqual({
|
||||
auth: expect.any(Object),
|
||||
httpAuth: expect.any(Object),
|
||||
userInfo: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward tokens if no token manager is provided', async () => {
|
||||
const { auth, httpAuth } = createLegacyAuthAdapters({
|
||||
auth: undefined,
|
||||
httpAuth: undefined,
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
const credentials = await httpAuth.credentials({
|
||||
headers: {
|
||||
authorization: 'Bearer my-token',
|
||||
},
|
||||
} as Request);
|
||||
|
||||
await expect(
|
||||
auth.getPluginRequestToken({
|
||||
onBehalfOf: credentials,
|
||||
targetPluginId: 'test',
|
||||
}),
|
||||
).resolves.toEqual({ token: 'my-token' });
|
||||
});
|
||||
|
||||
it('should issue a new token if a token manager is provided', async () => {
|
||||
const { auth, httpAuth } = createLegacyAuthAdapters({
|
||||
auth: undefined,
|
||||
httpAuth: undefined,
|
||||
tokenManager: {
|
||||
...mockTokenManager,
|
||||
async getToken() {
|
||||
return { token: 'new-token' };
|
||||
},
|
||||
},
|
||||
discovery: {} as any,
|
||||
});
|
||||
|
||||
const credentials = await httpAuth.credentials({
|
||||
headers: {
|
||||
authorization: 'Bearer mock-token',
|
||||
},
|
||||
} as Request);
|
||||
|
||||
await expect(
|
||||
auth.getPluginRequestToken({
|
||||
onBehalfOf: credentials,
|
||||
targetPluginId: 'test',
|
||||
}),
|
||||
).resolves.toEqual({ token: 'new-token' });
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserInfo,
|
||||
BackstageUserPrincipal,
|
||||
HttpAuthService,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
|
||||
import type { Request, Response } from 'express';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
createCredentialsWithServicePrincipal,
|
||||
createCredentialsWithUserPrincipal,
|
||||
createCredentialsWithNonePrincipal,
|
||||
toInternalBackstageCredentials,
|
||||
} from '../../../../backend-defaults/src/entrypoints/auth/helpers';
|
||||
// TODO is this circular thingy a problem? Test in e2e
|
||||
import {
|
||||
type IdentityApiGetIdentityRequest,
|
||||
DefaultIdentityClient,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { TokenManager, PluginEndpointDiscovery } from '../../deprecated';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { LegacyIdentityService } from '../legacy';
|
||||
|
||||
class AuthCompat implements AuthService {
|
||||
constructor(
|
||||
private readonly identity: LegacyIdentityService,
|
||||
private readonly tokenManager?: TokenManager,
|
||||
) {}
|
||||
|
||||
isPrincipal<TType extends keyof BackstagePrincipalTypes>(
|
||||
credentials: BackstageCredentials,
|
||||
type: TType,
|
||||
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]> {
|
||||
const principal = credentials.principal as
|
||||
| BackstageUserPrincipal
|
||||
| BackstageServicePrincipal;
|
||||
|
||||
if (principal.type !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getNoneCredentials(): Promise<
|
||||
BackstageCredentials<BackstageNonePrincipal>
|
||||
> {
|
||||
return createCredentialsWithNonePrincipal();
|
||||
}
|
||||
|
||||
async getOwnServiceCredentials(): Promise<
|
||||
BackstageCredentials<BackstageServicePrincipal>
|
||||
> {
|
||||
return createCredentialsWithServicePrincipal('external:backstage-plugin');
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<BackstageCredentials> {
|
||||
// Defensively check whether it seems token-like first, just to support
|
||||
// custom TokenManager implementations that don't emit JWTs specifically.
|
||||
const payload =
|
||||
token.split('.').length === 3 ? decodeJwt(token) : undefined;
|
||||
|
||||
if (payload?.aud === 'backstage') {
|
||||
// User Backstage token
|
||||
const identity = await this.identity.getIdentity({
|
||||
request: {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
},
|
||||
} as IdentityApiGetIdentityRequest);
|
||||
|
||||
if (!identity) {
|
||||
throw new AuthenticationError('Invalid user token');
|
||||
}
|
||||
|
||||
return createCredentialsWithUserPrincipal(
|
||||
identity.identity.userEntityRef,
|
||||
token,
|
||||
this.#getJwtExpiration(token),
|
||||
);
|
||||
}
|
||||
|
||||
await this.tokenManager?.authenticate(token);
|
||||
|
||||
return createCredentialsWithServicePrincipal(
|
||||
'external:backstage-plugin',
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
async getPluginRequestToken(options: {
|
||||
onBehalfOf: BackstageCredentials;
|
||||
targetPluginId: string;
|
||||
}): Promise<{ token: string }> {
|
||||
const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
|
||||
const { type } = internalForward.principal;
|
||||
|
||||
switch (type) {
|
||||
// TODO: Check whether the principal is ourselves
|
||||
case 'service': {
|
||||
if (this.tokenManager) {
|
||||
return this.tokenManager.getToken();
|
||||
}
|
||||
return { token: internalForward.token ?? '' };
|
||||
}
|
||||
case 'user':
|
||||
if (!internalForward.token) {
|
||||
throw new Error('User credentials is unexpectedly missing token');
|
||||
}
|
||||
return { token: internalForward.token };
|
||||
// NOTE: this is not the behavior of this service in the new backend system, it only applies
|
||||
// here since we'll need to accept and forward requests without authentication.
|
||||
case 'none':
|
||||
return { token: '' };
|
||||
default:
|
||||
throw new AuthenticationError(
|
||||
`Refused to issue service token for credential type '${type}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getLimitedUserToken(
|
||||
credentials: BackstageCredentials<BackstageUserPrincipal>,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
|
||||
const { token } = internalCredentials;
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError(
|
||||
'User credentials is unexpectedly missing token',
|
||||
);
|
||||
}
|
||||
|
||||
return { token, expiresAt: this.#getJwtExpiration(token) };
|
||||
}
|
||||
|
||||
#getJwtExpiration(token: string) {
|
||||
const { exp } = decodeJwt(token);
|
||||
if (!exp) {
|
||||
throw new AuthenticationError('User token is missing expiration');
|
||||
}
|
||||
return new Date(exp * 1000);
|
||||
}
|
||||
|
||||
listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromRequest(req: Request) {
|
||||
// TODO: support multiple auth headers (iterate rawHeaders)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (typeof authHeader === 'string') {
|
||||
const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
|
||||
const token = matches?.[1];
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentialsSymbol = Symbol('backstage-credentials');
|
||||
|
||||
type RequestWithCredentials = Request & {
|
||||
[credentialsSymbol]?: Promise<BackstageCredentials>;
|
||||
};
|
||||
|
||||
class HttpAuthCompat implements HttpAuthService {
|
||||
#auth: AuthService;
|
||||
|
||||
constructor(auth: AuthService) {
|
||||
this.#auth = auth;
|
||||
}
|
||||
|
||||
async #extractCredentialsFromRequest(req: Request) {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return this.#auth.getNoneCredentials();
|
||||
}
|
||||
|
||||
return this.#auth.authenticate(token);
|
||||
}
|
||||
|
||||
async #getCredentials(req: RequestWithCredentials) {
|
||||
return (req[credentialsSymbol] ??=
|
||||
this.#extractCredentialsFromRequest(req));
|
||||
}
|
||||
|
||||
async credentials<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
|
||||
req: Request,
|
||||
options?: {
|
||||
allow?: Array<TAllowed>;
|
||||
allowLimitedAccess?: boolean;
|
||||
},
|
||||
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>> {
|
||||
const credentials = await this.#getCredentials(req);
|
||||
|
||||
const allowed = options?.allow;
|
||||
if (!allowed) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
if (this.#auth.isPrincipal(credentials, 'none')) {
|
||||
if (allowed.includes('none' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Missing credentials');
|
||||
} else if (this.#auth.isPrincipal(credentials, 'user')) {
|
||||
if (allowed.includes('user' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow 'user' credentials`,
|
||||
);
|
||||
} else if (this.#auth.isPrincipal(credentials, 'service')) {
|
||||
if (allowed.includes('service' as TAllowed)) {
|
||||
return credentials as any;
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
`This endpoint does not allow 'service' credentials`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new NotAllowedError(
|
||||
'Unknown principal type, this should never happen',
|
||||
);
|
||||
}
|
||||
|
||||
async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> {
|
||||
return { expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfoCompat implements UserInfoService {
|
||||
async getUserInfo(
|
||||
credentials: BackstageCredentials,
|
||||
): Promise<BackstageUserInfo> {
|
||||
const internalCredentials = toInternalBackstageCredentials(credentials);
|
||||
if (internalCredentials.principal.type !== 'user') {
|
||||
throw new Error('Only user credentials are supported');
|
||||
}
|
||||
if (!internalCredentials.token) {
|
||||
throw new Error('User credentials is unexpectedly missing token');
|
||||
}
|
||||
const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt(
|
||||
internalCredentials.token,
|
||||
);
|
||||
|
||||
if (typeof userEntityRef !== 'string') {
|
||||
throw new Error('User entity ref must be a string');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(ownershipEntityRefs) ||
|
||||
ownershipEntityRefs.some(ref => typeof ref !== 'string')
|
||||
) {
|
||||
throw new Error('Ownership entity refs must be an array of strings');
|
||||
}
|
||||
|
||||
return { userEntityRef, ownershipEntityRefs };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An adapter that ensures presence of the auth and/or httpAuth services.
|
||||
* @public
|
||||
* @deprecated Migrate to use the new backend system and auth services instead.
|
||||
*/
|
||||
export function createLegacyAuthAdapters<
|
||||
TOptions extends {
|
||||
auth?: AuthService;
|
||||
httpAuth?: HttpAuthService;
|
||||
userInfo?: UserInfoService;
|
||||
identity?: LegacyIdentityService;
|
||||
tokenManager?: TokenManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
},
|
||||
TAdapters = (TOptions extends { auth?: AuthService }
|
||||
? { auth: AuthService }
|
||||
: {}) &
|
||||
(TOptions extends { httpAuth?: HttpAuthService }
|
||||
? { httpAuth: HttpAuthService }
|
||||
: {}) &
|
||||
(TOptions extends { userInfo?: UserInfoService }
|
||||
? { userInfo: UserInfoService }
|
||||
: {}),
|
||||
>(options: TOptions): TAdapters {
|
||||
const {
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo = new UserInfoCompat(),
|
||||
discovery,
|
||||
} = options;
|
||||
|
||||
if (auth && httpAuth) {
|
||||
return {
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
return {
|
||||
auth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
if (httpAuth) {
|
||||
return {
|
||||
httpAuth,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
|
||||
const identity =
|
||||
options.identity ?? DefaultIdentityClient.create({ discovery });
|
||||
|
||||
const authImpl = new AuthCompat(identity, options.tokenManager);
|
||||
|
||||
const httpAuthImpl = new HttpAuthCompat(authImpl);
|
||||
|
||||
return {
|
||||
auth: authImpl,
|
||||
httpAuth: httpAuthImpl,
|
||||
userInfo,
|
||||
} as TAdapters;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { createLegacyAuthAdapters } from './createLegacyAuthAdapters';
|
||||
@@ -1,35 +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 {
|
||||
CacheService,
|
||||
CacheServiceOptions,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Compatibility wrapper for going from a new-backend cache service to the
|
||||
* old-backend plugin cache manager.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Migrate to use the new CacheService instead.
|
||||
*/
|
||||
export function cacheToPluginCacheManager(cache: CacheService): {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
} {
|
||||
return {
|
||||
getClient: (opts: CacheServiceOptions) => cache.withOptions(opts),
|
||||
};
|
||||
}
|
||||
@@ -1,17 +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.
|
||||
*/
|
||||
|
||||
export { cacheToPluginCacheManager } from './cacheToPluginCacheManager';
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './legacy';
|
||||
export * from './auth';
|
||||
export * from './cache';
|
||||
export * from './logging';
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { legacyPlugin, makeLegacyPlugin } from './legacy';
|
||||
export type { LegacyCreateRouter, LegacyIdentityService } from './legacy';
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
mockCredentials,
|
||||
mockServices,
|
||||
startTestBackend,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Router } from 'express';
|
||||
import request from 'supertest';
|
||||
import { createLegacyAuthAdapters } from '..';
|
||||
import { legacyPlugin } from './legacy';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { authServiceFactory } from '../../../../backend-defaults/src/entrypoints/auth';
|
||||
|
||||
describe('legacyPlugin', () => {
|
||||
it('can auth across the new and old systems', async () => {
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
const done = new Promise(resolve => {
|
||||
emitter.once('done', () => {
|
||||
emitter.once('done', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
await startTestBackend({
|
||||
features: [
|
||||
authServiceFactory,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [
|
||||
{
|
||||
secret: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
createBackendPlugin({
|
||||
pluginId: 'new',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
auth: coreServices.auth,
|
||||
discovery: coreServices.discovery,
|
||||
},
|
||||
async init({ auth }) {
|
||||
emitter.once('legacy-token', async otherToken => {
|
||||
const credentials = await auth.authenticate(otherToken);
|
||||
expect(credentials.principal).toEqual({
|
||||
type: 'service',
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
emitter.emit('done');
|
||||
});
|
||||
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
onBehalfOf: await auth.getOwnServiceCredentials(),
|
||||
targetPluginId: 'old',
|
||||
});
|
||||
emitter.emit('new-token', token);
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
legacyPlugin(
|
||||
'old',
|
||||
Promise.resolve({
|
||||
async default({ tokenManager, identity, discovery }) {
|
||||
const { auth } = createLegacyAuthAdapters({
|
||||
tokenManager,
|
||||
identity,
|
||||
discovery,
|
||||
auth: undefined as any as typeof coreServices.auth.T,
|
||||
httpAuth: undefined as any as typeof coreServices.httpAuth.T,
|
||||
});
|
||||
|
||||
emitter.once('new-token', async otherToken => {
|
||||
const credentials = await auth.authenticate(otherToken);
|
||||
expect(credentials.principal).toEqual({
|
||||
type: 'service',
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
emitter.emit('done');
|
||||
});
|
||||
|
||||
const { token } = await tokenManager.getToken();
|
||||
emitter.emit('legacy-token', token);
|
||||
|
||||
return Router();
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await done;
|
||||
});
|
||||
|
||||
it('can auth users with the identity service shim', async () => {
|
||||
const backend = await startTestBackend({
|
||||
features: [
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [
|
||||
{
|
||||
secret: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
legacyPlugin(
|
||||
'test',
|
||||
Promise.resolve({
|
||||
async default({ identity }) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
return router;
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(backend.server)
|
||||
.get('/api/test')
|
||||
.set('authorization', mockCredentials.user.header());
|
||||
|
||||
const mockUserRef = mockCredentials.user().principal.userEntityRef;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
token: mockCredentials.user.token(),
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: mockUserRef,
|
||||
ownershipEntityRefs: [mockUserRef],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthService,
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
ServiceRef,
|
||||
UserInfoService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { RequestHandler } from 'express';
|
||||
import { cacheToPluginCacheManager } from '../cache';
|
||||
import { loggerToWinstonLogger } from '../logging';
|
||||
import { ServerTokenManager, TokenManager } from '../../deprecated';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Fully use the new backend system instead.
|
||||
*/
|
||||
export type LegacyCreateRouter<TEnv> = (deps: TEnv) => Promise<RequestHandler>;
|
||||
|
||||
/** @ignore */
|
||||
type TransformedEnv<
|
||||
TEnv extends Record<string, unknown>,
|
||||
TEnvTransforms extends { [key in keyof TEnv]?: (dep: TEnv[key]) => unknown },
|
||||
> = {
|
||||
[key in keyof TEnv]: TEnvTransforms[key] extends (dep: TEnv[key]) => infer R
|
||||
? R
|
||||
: TEnv[key];
|
||||
};
|
||||
|
||||
// Since the plugin will be using the new system our callers will expect us to support the
|
||||
// new plugin tokens, which we'll also be signaling by supporting the JWKS endpoint through
|
||||
// the http router.
|
||||
// This makes sure that we accept the new plugin tokens as valid tokens, but otherwise fall
|
||||
// back to the legacy token manager.
|
||||
function createTokenManagerShim(
|
||||
auth: AuthService,
|
||||
config: RootConfigService,
|
||||
logger: LoggerService,
|
||||
): TokenManager {
|
||||
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
|
||||
return {
|
||||
async getToken() {
|
||||
return tokenManager.getToken();
|
||||
},
|
||||
async authenticate(token) {
|
||||
if (token) {
|
||||
// Unless it's a valid service token, we'll let the token manager do
|
||||
// validation. We'll throw if we for example receive an invalid user
|
||||
// token here, but that's what the token manager does too.
|
||||
const credentials = await auth.authenticate(token);
|
||||
if (auth.isPrincipal(credentials, 'service')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await tokenManager.authenticate(token);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Originally IdentityApi from `@backstage/plugin-auth-node`, re-declared here for backwards compatibility
|
||||
* @public
|
||||
* @deprecated Only relevant for legacy plugins, which are deprecated.
|
||||
*/
|
||||
export interface LegacyIdentityService {
|
||||
getIdentity(options: { request: Request<unknown> }): Promise<
|
||||
| {
|
||||
expiresInSeconds?: number;
|
||||
|
||||
token: string;
|
||||
identity: {
|
||||
type: 'user';
|
||||
userEntityRef: string;
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// This doesn't use DefaultIdentityClient because we will be removing it and break support for ownershipEntityRefs
|
||||
function createIdentityServiceShim(
|
||||
auth: AuthService,
|
||||
userInfo: UserInfoService,
|
||||
): LegacyIdentityService {
|
||||
return {
|
||||
async getIdentity(options) {
|
||||
const authHeader = options.request.headers.authorization;
|
||||
if (typeof authHeader !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const token = authHeader.match(/^Bearer[ ]+(\S+)$/i)?.[1];
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = await auth.authenticate(token);
|
||||
if (!auth.isPrincipal(credentials, 'user')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info = await userInfo.getUserInfo(credentials);
|
||||
|
||||
return {
|
||||
token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: info.userEntityRef,
|
||||
ownershipEntityRefs: info.ownershipEntityRefs,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new custom plugin compatibility wrapper.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Fully use the new backend system instead.
|
||||
* @remarks
|
||||
*
|
||||
* Usually you can use {@link legacyPlugin} directly instead, but you might
|
||||
* need to use this if you have customized the plugin environment in your backend.
|
||||
*/
|
||||
export function makeLegacyPlugin<
|
||||
TEnv extends Record<string, unknown>,
|
||||
TEnvTransforms extends { [key in keyof TEnv]?: (dep: TEnv[key]) => unknown },
|
||||
>(
|
||||
envMapping: { [key in keyof TEnv]: ServiceRef<TEnv[key]> },
|
||||
envTransforms: TEnvTransforms,
|
||||
) {
|
||||
return (
|
||||
name: string,
|
||||
createRouterImport: Promise<{
|
||||
default: LegacyCreateRouter<
|
||||
TransformedEnv<TEnv, TEnvTransforms> & {
|
||||
tokenManager: TokenManager;
|
||||
identity: LegacyIdentityService;
|
||||
}
|
||||
>;
|
||||
}>,
|
||||
) => {
|
||||
return createBackendPlugin({
|
||||
pluginId: name,
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
...envMapping,
|
||||
$$router: coreServices.httpRouter,
|
||||
$$auth: coreServices.auth,
|
||||
$$userInfo: coreServices.userInfo,
|
||||
$$config: coreServices.rootConfig,
|
||||
$$logger: coreServices.logger,
|
||||
},
|
||||
async init({
|
||||
$$auth,
|
||||
$$config,
|
||||
$$logger,
|
||||
$$router,
|
||||
$$userInfo,
|
||||
...envDeps
|
||||
}) {
|
||||
const { default: createRouter } = await createRouterImport;
|
||||
const pluginEnv = Object.fromEntries(
|
||||
Object.entries(envDeps).map(([key, dep]) => {
|
||||
const transform = envTransforms[key];
|
||||
if (transform) {
|
||||
return [key, transform(dep)];
|
||||
}
|
||||
return [key, dep];
|
||||
}),
|
||||
);
|
||||
|
||||
const auth = $$auth as typeof coreServices.auth.T;
|
||||
const config = $$config as typeof coreServices.rootConfig.T;
|
||||
const logger = $$logger as typeof coreServices.logger.T;
|
||||
const router = $$router as typeof coreServices.httpRouter.T;
|
||||
const userInfo = $$userInfo as typeof coreServices.userInfo.T;
|
||||
|
||||
// Token manager and identity services are no longer supported in the new backend system, so we provide shims for them.
|
||||
pluginEnv.tokenManager = createTokenManagerShim(
|
||||
auth,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
pluginEnv.identity = createIdentityServiceShim(auth, userInfo);
|
||||
|
||||
const pluginRouter = await createRouter(
|
||||
pluginEnv as TransformedEnv<TEnv, TEnvTransforms> & {
|
||||
tokenManager: TokenManager;
|
||||
identity: LegacyIdentityService;
|
||||
},
|
||||
);
|
||||
router.use(pluginRouter);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a plugin from a legacy createRouter function and
|
||||
* register it with the http router based on the plugin id.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Fully use the new backend system instead.
|
||||
* @remarks
|
||||
*
|
||||
* This is intended to be used by plugin authors to ease the transition to the
|
||||
* new backend system.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
*```ts
|
||||
*backend.add(legacyPlugin('kafka', import('./plugins/kafka')));
|
||||
*```
|
||||
*/
|
||||
export const legacyPlugin = makeLegacyPlugin(
|
||||
{
|
||||
cache: coreServices.cache,
|
||||
config: coreServices.rootConfig,
|
||||
database: coreServices.database,
|
||||
discovery: coreServices.discovery,
|
||||
logger: coreServices.logger,
|
||||
permissions: coreServices.permissions,
|
||||
scheduler: coreServices.scheduler,
|
||||
reader: coreServices.urlReader,
|
||||
},
|
||||
{
|
||||
logger: log => loggerToWinstonLogger(log),
|
||||
cache: cache => cacheToPluginCacheManager(cache),
|
||||
},
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { loggerToWinstonLogger } from './loggerToWinstonLogger';
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Logger as WinstonLogger, createLogger } from 'winston';
|
||||
import Transport, { TransportStreamOptions } from 'winston-transport';
|
||||
|
||||
class BackstageLoggerTransport extends Transport {
|
||||
constructor(
|
||||
private readonly backstageLogger: LoggerService,
|
||||
opts?: TransportStreamOptions,
|
||||
) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
log(info: unknown, callback: VoidFunction) {
|
||||
if (typeof info !== 'object' || info === null) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const { level, message, ...meta } = info as JsonObject;
|
||||
switch (level) {
|
||||
case 'error':
|
||||
this.backstageLogger.error(String(message), meta);
|
||||
break;
|
||||
case 'warn':
|
||||
this.backstageLogger.warn(String(message), meta);
|
||||
break;
|
||||
case 'info':
|
||||
this.backstageLogger.info(String(message), meta);
|
||||
break;
|
||||
case 'debug':
|
||||
this.backstageLogger.debug(String(message), meta);
|
||||
break;
|
||||
default:
|
||||
this.backstageLogger.info(String(message), meta);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Migrate to use the new LoggerService instead.
|
||||
*/
|
||||
export function loggerToWinstonLogger(
|
||||
logger: LoggerService,
|
||||
opts?: TransportStreamOptions,
|
||||
): WinstonLogger {
|
||||
return createLogger({
|
||||
transports: [new BackstageLoggerTransport(logger, opts)],
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ObservableConfigProxy } from './ObservableConfigProxy';
|
||||
|
||||
describe('ObservableConfigProxy', () => {
|
||||
it('should notify subscribers', () => {
|
||||
const config = new ObservableConfigProxy();
|
||||
|
||||
const fn = jest.fn();
|
||||
const sub = config.subscribe(fn);
|
||||
expect(config.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config.setConfig(new ConfigReader({}));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(config.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 1 }));
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(config.getOptionalNumber('x')).toBe(1);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 3 }));
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
sub.unsubscribe();
|
||||
expect(config.getOptionalNumber('x')).toBe(3);
|
||||
|
||||
config.setConfig(new ConfigReader({ x: 5 }));
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(config.getOptionalNumber('x')).toBe(5);
|
||||
});
|
||||
|
||||
it('should forward subscriptions', () => {
|
||||
const config1 = new ObservableConfigProxy();
|
||||
|
||||
const fn1 = jest.fn();
|
||||
const fn2 = jest.fn();
|
||||
const fn3 = jest.fn();
|
||||
const config2 = config1.getConfig('a');
|
||||
const config3 = config2.getConfig('b');
|
||||
const sub1 = config1.subscribe(fn1);
|
||||
const sub2 = config2.subscribe!(fn2);
|
||||
const sub3 = config3.subscribe!(fn3);
|
||||
expect(config1.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config2.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config3.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config1.setConfig(new ConfigReader({}));
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
expect(fn3).toHaveBeenCalledTimes(1);
|
||||
expect(config1.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config2.getOptionalNumber('x')).toBe(undefined);
|
||||
expect(config3.getOptionalNumber('x')).toBe(undefined);
|
||||
|
||||
config1.setConfig(new ConfigReader({ x: 1, a: { x: 2, b: { x: 3 } } }));
|
||||
expect(fn1).toHaveBeenCalledTimes(2);
|
||||
expect(fn2).toHaveBeenCalledTimes(2);
|
||||
expect(fn3).toHaveBeenCalledTimes(2);
|
||||
expect(config1.getNumber('x')).toBe(1);
|
||||
expect(config2.getNumber('x')).toBe(2);
|
||||
expect(config3.getNumber('x')).toBe(3);
|
||||
|
||||
sub1.unsubscribe();
|
||||
sub2.unsubscribe();
|
||||
sub3.unsubscribe();
|
||||
|
||||
config1.setConfig(new ConfigReader({ x: 4, a: { x: 5, b: { x: 6 } } }));
|
||||
expect(fn1).toHaveBeenCalledTimes(2);
|
||||
expect(fn2).toHaveBeenCalledTimes(2);
|
||||
expect(fn3).toHaveBeenCalledTimes(2);
|
||||
expect(config1.getNumber('x')).toBe(4);
|
||||
expect(config2.getNumber('x')).toBe(5);
|
||||
expect(config3.getNumber('x')).toBe(6);
|
||||
|
||||
config1.setConfig(new ConfigReader({}));
|
||||
expect(() => config1.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'x'",
|
||||
);
|
||||
expect(() => config2.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'a'",
|
||||
);
|
||||
expect(() => config3.getNumber('x')).toThrow(
|
||||
"Missing required config value at 'a'",
|
||||
);
|
||||
|
||||
config1.setConfig(
|
||||
new ConfigReader({ x: 's', a: { x: 's', b: { x: 's' } } }),
|
||||
);
|
||||
expect(() => config1.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'x' in 'mock-config' to a number",
|
||||
);
|
||||
expect(() => config2.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'a.x' in 'mock-config' to a number",
|
||||
);
|
||||
expect(() => config3.getNumber('x')).toThrow(
|
||||
"Unable to convert config value for key 'a.b.x' in 'mock-config' to a number",
|
||||
);
|
||||
});
|
||||
|
||||
it('should make sub configs available as expected', () => {
|
||||
const config = new ObservableConfigProxy();
|
||||
|
||||
config.setConfig(new ConfigReader({ a: { x: 1 } }));
|
||||
|
||||
expect(config.getConfig('a')).toBeDefined();
|
||||
expect(config.getConfig('a').getNumber('x')).toBe(1);
|
||||
expect(config.getConfig('a').getOptionalNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('a')?.getNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('a')?.getOptionalNumber('x')).toBe(1);
|
||||
expect(config.getOptionalConfig('b')).toBeUndefined();
|
||||
expect(() => config.getConfig('b')).toBeDefined();
|
||||
expect(() => config.getConfig('b').get()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export class ObservableConfigProxy implements Config {
|
||||
private config: Config = new ConfigReader({});
|
||||
|
||||
private readonly subscribers: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly parent?: ObservableConfigProxy,
|
||||
private parentKey?: string,
|
||||
) {
|
||||
if (parent && !parentKey) {
|
||||
throw new Error('parentKey is required if parent is set');
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(config: Config) {
|
||||
if (this.parent) {
|
||||
throw new Error('immutable');
|
||||
}
|
||||
this.config = config;
|
||||
for (const subscriber of this.subscribers) {
|
||||
try {
|
||||
subscriber();
|
||||
} catch (error) {
|
||||
console.error(`Config subscriber threw error, ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(onChange: () => void): { unsubscribe: () => void } {
|
||||
if (this.parent) {
|
||||
return this.parent.subscribe(onChange);
|
||||
}
|
||||
|
||||
this.subscribers.push(onChange);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
const index = this.subscribers.indexOf(onChange);
|
||||
if (index >= 0) {
|
||||
this.subscribers.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private select(required: true): Config;
|
||||
private select(required: false): Config | undefined;
|
||||
private select(required: boolean): Config | undefined {
|
||||
if (this.parent && this.parentKey) {
|
||||
if (required) {
|
||||
return this.parent.select(true).getConfig(this.parentKey);
|
||||
}
|
||||
return this.parent.select(false)?.getOptionalConfig(this.parentKey);
|
||||
}
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.select(false)?.has(key) ?? false;
|
||||
}
|
||||
keys(): string[] {
|
||||
return this.select(false)?.keys() ?? [];
|
||||
}
|
||||
get<T = JsonValue>(key?: string): T {
|
||||
return this.select(true).get(key);
|
||||
}
|
||||
getOptional<T = JsonValue>(key?: string): T | undefined {
|
||||
return this.select(false)?.getOptional(key);
|
||||
}
|
||||
getConfig(key: string): Config {
|
||||
return new ObservableConfigProxy(this, key);
|
||||
}
|
||||
getOptionalConfig(key: string): Config | undefined {
|
||||
if (this.select(false)?.has(key)) {
|
||||
return new ObservableConfigProxy(this, key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
getConfigArray(key: string): Config[] {
|
||||
return this.select(true).getConfigArray(key);
|
||||
}
|
||||
getOptionalConfigArray(key: string): Config[] | undefined {
|
||||
return this.select(false)?.getOptionalConfigArray(key);
|
||||
}
|
||||
getNumber(key: string): number {
|
||||
return this.select(true).getNumber(key);
|
||||
}
|
||||
getOptionalNumber(key: string): number | undefined {
|
||||
return this.select(false)?.getOptionalNumber(key);
|
||||
}
|
||||
getBoolean(key: string): boolean {
|
||||
return this.select(true).getBoolean(key);
|
||||
}
|
||||
getOptionalBoolean(key: string): boolean | undefined {
|
||||
return this.select(false)?.getOptionalBoolean(key);
|
||||
}
|
||||
getString(key: string): string {
|
||||
return this.select(true).getString(key);
|
||||
}
|
||||
getOptionalString(key: string): string | undefined {
|
||||
return this.select(false)?.getOptionalString(key);
|
||||
}
|
||||
getStringArray(key: string): string[] {
|
||||
return this.select(true).getStringArray(key);
|
||||
}
|
||||
getOptionalStringArray(key: string): string[] | undefined {
|
||||
return this.select(false)?.getOptionalStringArray(key);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { loadConfigSchema } from '@backstage/config-loader';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { createConfigSecretEnumerator } from './config';
|
||||
|
||||
describe('createConfigSecretEnumerator', () => {
|
||||
it('should enumerate secrets', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
const enumerate = await createConfigSecretEnumerator({
|
||||
logger,
|
||||
});
|
||||
const secrets = enumerate(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: { auth: { keys: [{ secret: 'my-secret-password' }] } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(Array.from(secrets)).toEqual(['my-secret-password']);
|
||||
}, 20_000); // Bit higher timeout since we're loading all config schemas in the repo
|
||||
|
||||
it('should enumerate secrets with explicit schema', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
const enumerate = await createConfigSecretEnumerator({
|
||||
logger,
|
||||
schema: await loadConfigSchema({
|
||||
serialized: {
|
||||
schemas: [
|
||||
{
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
secret: {
|
||||
visibility: 'secret',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
path: '/mock',
|
||||
},
|
||||
],
|
||||
backstageConfigSchemaVersion: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const secrets = enumerate(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
secret: 'my-secret',
|
||||
other: 'not-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(Array.from(secrets)).toEqual(['my-secret']);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { setRootLoggerRedactionList } from '../logging/createRootLogger';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { createConfigSecretEnumerator as _createConfigSecretEnumerator } from '../../../../backend-defaults/src/entrypoints/rootConfig/createConfigSecretEnumerator';
|
||||
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import parseArgs from 'minimist';
|
||||
import { findPaths } from '@backstage/cli-common';
|
||||
import {
|
||||
loadConfig,
|
||||
ConfigTarget,
|
||||
LoadConfigOptionsRemote,
|
||||
} from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ObservableConfigProxy } from './ObservableConfigProxy';
|
||||
import { isValidUrl } from './urls';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Please migrate to the new backend system and use `coreServices.rootConfig` instead, or the {@link @backstage/config-loader#ConfigSources} facilities if required.
|
||||
*/
|
||||
export const createConfigSecretEnumerator = _createConfigSecretEnumerator;
|
||||
|
||||
/**
|
||||
* Load configuration for a Backend.
|
||||
*
|
||||
* This function should only be called once, during the initialization of the backend.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Please migrate to the new backend system and use `coreServices.rootConfig` instead, or the {@link @backstage/config-loader#ConfigSources} facilities if required.
|
||||
*/
|
||||
export async function loadBackendConfig(options: {
|
||||
logger: LoggerService;
|
||||
// process.argv or any other overrides
|
||||
remote?: LoadConfigOptionsRemote;
|
||||
additionalConfigs?: AppConfig[];
|
||||
argv: string[];
|
||||
watch?: boolean;
|
||||
}): Promise<Config> {
|
||||
const secretEnumerator = await createConfigSecretEnumerator({
|
||||
logger: options.logger,
|
||||
});
|
||||
const { config } = await newLoadBackendConfig(options);
|
||||
|
||||
setRootLoggerRedactionList(secretEnumerator(config));
|
||||
config.subscribe?.(() =>
|
||||
setRootLoggerRedactionList(secretEnumerator(config)),
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function newLoadBackendConfig(options: {
|
||||
remote?: LoadConfigOptionsRemote;
|
||||
argv: string[];
|
||||
additionalConfigs?: AppConfig[];
|
||||
watch?: boolean;
|
||||
}): Promise<{ config: Config }> {
|
||||
const args = parseArgs(options.argv);
|
||||
|
||||
const configTargets: ConfigTarget[] = [args.config ?? []]
|
||||
.flat()
|
||||
.map(arg => (isValidUrl(arg) ? { url: arg } : { path: resolvePath(arg) }));
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const paths = findPaths(__dirname);
|
||||
|
||||
let currentCancelFunc: (() => void) | undefined = undefined;
|
||||
|
||||
const config = new ObservableConfigProxy();
|
||||
const { appConfigs } = await loadConfig({
|
||||
configRoot: paths.targetRoot,
|
||||
configTargets: configTargets,
|
||||
remote: options.remote,
|
||||
watch:
|
||||
options.watch ?? true
|
||||
? {
|
||||
onChange(newConfigs) {
|
||||
console.info(
|
||||
`Reloaded config from ${newConfigs
|
||||
.map(c => c.context)
|
||||
.join(', ')}`,
|
||||
);
|
||||
const configsToMerge = [...newConfigs];
|
||||
if (options.additionalConfigs) {
|
||||
configsToMerge.push(...options.additionalConfigs);
|
||||
}
|
||||
config.setConfig(ConfigReader.fromConfigs(configsToMerge));
|
||||
},
|
||||
stopSignal: new Promise(resolve => {
|
||||
if (currentCancelFunc) {
|
||||
currentCancelFunc();
|
||||
}
|
||||
currentCancelFunc = resolve;
|
||||
|
||||
// TODO(Rugvip): We keep this here for now to avoid breaking the old system
|
||||
// since this is re-used in backend-common
|
||||
if (module.hot) {
|
||||
module.hot.addDisposeHandler(resolve);
|
||||
}
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
console.info(
|
||||
`Loaded config from ${appConfigs.map(c => c.context).join(', ')}`,
|
||||
);
|
||||
|
||||
const finalAppConfigs = [...appConfigs];
|
||||
if (options.additionalConfigs) {
|
||||
finalAppConfigs.push(...options.additionalConfigs);
|
||||
}
|
||||
config.setConfig(ConfigReader.fromConfigs(finalAppConfigs));
|
||||
|
||||
return { config };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { loadBackendConfig, createConfigSecretEnumerator } from './config';
|
||||
@@ -1,34 +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 { isValidUrl } from './urls';
|
||||
|
||||
describe('isValidUrl', () => {
|
||||
it('should return true for url', () => {
|
||||
const validUrl = isValidUrl('http://some.valid.url');
|
||||
expect(validUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for absolute path', () => {
|
||||
const validUrl = isValidUrl('/some/absolute/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for relative path', () => {
|
||||
const validUrl = isValidUrl('../some/relative/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +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.
|
||||
*/
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,344 +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 { AbortContext } from './AbortContext';
|
||||
import { RootContext } from './RootContext';
|
||||
|
||||
describe('AbortContext', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('forTimeoutMillis', () => {
|
||||
it('can abort on a timeout', async () => {
|
||||
jest.useFakeTimers();
|
||||
const timeout = 200;
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
const root = new RootContext();
|
||||
|
||||
const child = AbortContext.forTimeoutMillis(root, timeout);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(Math.abs(+child.deadline! - deadline)).toBeLessThan(50);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
jest.advanceTimersByTime(timeout + 1);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('results in minimum deadline when parent triggers sooner', async () => {
|
||||
jest.useFakeTimers();
|
||||
const parentTimeout = 200;
|
||||
const childTimeout = 300;
|
||||
const parentDeadline = Date.now() + parentTimeout;
|
||||
const childDeadline = parentDeadline; // clamped
|
||||
|
||||
const root = new RootContext();
|
||||
|
||||
const parent = AbortContext.forTimeoutMillis(root, parentTimeout);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const child = AbortContext.forTimeoutMillis(parent, childTimeout);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50);
|
||||
expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
jest.advanceTimersByTime(parentTimeout + 1);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(1);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('results in minimum deadline when child triggers sooner', async () => {
|
||||
jest.useFakeTimers();
|
||||
const parentTimeout = 300;
|
||||
const childTimeout = 200;
|
||||
const parentDeadline = Date.now() + parentTimeout;
|
||||
const childDeadline = Date.now() + childTimeout;
|
||||
|
||||
const root = new RootContext();
|
||||
|
||||
const parent = AbortContext.forTimeoutMillis(root, parentTimeout);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const child = AbortContext.forTimeoutMillis(parent, childTimeout);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50);
|
||||
expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
jest.advanceTimersByTime(childTimeout + 1);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(parentTimeout - childTimeout + 1);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(1);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('child carries over parent signal state if parent was already aborted and had no deadline', async () => {
|
||||
jest.useFakeTimers();
|
||||
const childTimeout = 200;
|
||||
const childDeadline = Date.now() + childTimeout;
|
||||
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forSignal(root, parentController.signal);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
const child = AbortContext.forTimeoutMillis(parent, childTimeout);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50);
|
||||
|
||||
jest.advanceTimersByTime(childTimeout + 1);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0); // still
|
||||
});
|
||||
|
||||
it('child carries over parent signal state if parent was already aborted and had a deadline', async () => {
|
||||
jest.useFakeTimers();
|
||||
const first = new RootContext();
|
||||
|
||||
const secondController = new AbortController();
|
||||
const second = AbortContext.forSignal(first, secondController.signal);
|
||||
secondController.abort();
|
||||
|
||||
const third = AbortContext.forTimeoutMillis(second, 200);
|
||||
const fourth = AbortContext.forTimeoutMillis(third, 300);
|
||||
|
||||
expect(third.abortSignal.aborted).toBe(true);
|
||||
expect(fourth.abortSignal.aborted).toBe(true);
|
||||
expect(Math.abs(+fourth.deadline! - Date.now() - 200)).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forController', () => {
|
||||
it('signals child when parent is aborted', () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forController(root, parentController);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forController(parent, childController);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(1);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not signal parent when child is aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forController(root, parentController);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forController(parent, childController);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
childController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('child carries over parent signal state if parent was already aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forController(root, parentController);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forController(parent, childController);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
childController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('child carries over given signal state if it was already aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const childController = new AbortController();
|
||||
childController.abort();
|
||||
|
||||
const child = AbortContext.forController(root, childController);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forSignal', () => {
|
||||
it('signals child when parent is aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forSignal(root, parentController.signal);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forSignal(parent, childController.signal);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(1);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not signal parent when child is aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forSignal(root, parentController.signal);
|
||||
const parentListener = jest.fn();
|
||||
parent.abortSignal.addEventListener('abort', parentListener);
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forSignal(parent, childController.signal);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
childController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(false);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(parentListener).toHaveBeenCalledTimes(0);
|
||||
expect(childListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('child carries over parent signal state if parent was already aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const parentController = new AbortController();
|
||||
const parent = AbortContext.forSignal(root, parentController.signal);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
const childController = new AbortController();
|
||||
const child = AbortContext.forSignal(parent, childController.signal);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
|
||||
childController.abort();
|
||||
|
||||
expect(parent.abortSignal.aborted).toBe(true);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('child carries over given signal state if it was already aborted', async () => {
|
||||
const root = new RootContext();
|
||||
|
||||
const childController = new AbortController();
|
||||
childController.abort();
|
||||
|
||||
const child = AbortContext.forSignal(root, childController.signal);
|
||||
const childListener = jest.fn();
|
||||
child.abortSignal.addEventListener('abort', childListener);
|
||||
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
expect(childListener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +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 { Context } from './types';
|
||||
|
||||
/**
|
||||
* A context that implements various abort related functionality.
|
||||
*/
|
||||
export class AbortContext implements Context {
|
||||
/**
|
||||
* Abort either when the parent aborts, or after the given timeout has
|
||||
* expired.
|
||||
*
|
||||
* @param ctx - The parent context
|
||||
* @param timeout - A timeout value, in milliseconds
|
||||
* @returns A new context
|
||||
*/
|
||||
static forTimeoutMillis(ctx: Context, timeout: number): Context {
|
||||
const desiredDeadline = new Date(Date.now() + timeout);
|
||||
const actualDeadline =
|
||||
ctx.deadline && ctx.deadline < desiredDeadline
|
||||
? ctx.deadline
|
||||
: desiredDeadline;
|
||||
|
||||
if (ctx.abortSignal.aborted) {
|
||||
if (ctx.deadline && desiredDeadline === actualDeadline) {
|
||||
return ctx;
|
||||
}
|
||||
return new AbortContext(ctx, ctx.abortSignal, actualDeadline);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(abort, timeout);
|
||||
ctx.abortSignal.addEventListener('abort', abort);
|
||||
|
||||
function abort() {
|
||||
ctx.abortSignal.removeEventListener('abort', abort);
|
||||
clearTimeout(timeoutHandle!);
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
return new AbortContext(ctx, controller.signal, actualDeadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort either when the parent aborts, or when the given controller is
|
||||
* triggered.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If you have access to the controller, this function is more efficient than
|
||||
* {@link AbortContext#forSignal}.
|
||||
*
|
||||
* @param ctx - The parent context
|
||||
* @param controller - An abort controller
|
||||
* @returns A new context
|
||||
*/
|
||||
static forController(ctx: Context, controller: AbortController): Context {
|
||||
// Already aborted context / signal are fine to reuse as-is
|
||||
if (ctx.abortSignal.aborted) {
|
||||
return ctx;
|
||||
} else if (controller.signal.aborted) {
|
||||
return new AbortContext(ctx, controller.signal, ctx.deadline);
|
||||
}
|
||||
|
||||
function abort() {
|
||||
ctx.abortSignal.removeEventListener('abort', abort);
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
ctx.abortSignal.addEventListener('abort', abort);
|
||||
|
||||
return new AbortContext(ctx, controller.signal, ctx.deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort either when the parent aborts, or when the given signal is triggered.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If you have access to the controller and not just the signal,
|
||||
* {@link AbortContext#forController} is slightly more efficient to use.
|
||||
*
|
||||
* @param ctx - The parent context
|
||||
* @param signal - An abort signal
|
||||
* @returns A new context
|
||||
*/
|
||||
static forSignal(ctx: Context, signal: AbortSignal): Context {
|
||||
// Already aborted context / signal are fine to reuse as-is
|
||||
if (ctx.abortSignal.aborted) {
|
||||
return ctx;
|
||||
} else if (signal.aborted) {
|
||||
return new AbortContext(ctx, signal, ctx.deadline);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
function abort() {
|
||||
ctx.abortSignal.removeEventListener('abort', abort);
|
||||
signal.removeEventListener('abort', abort);
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
ctx.abortSignal.addEventListener('abort', abort);
|
||||
signal.addEventListener('abort', abort);
|
||||
|
||||
return new AbortContext(ctx, controller.signal, ctx.deadline);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly parent: Context,
|
||||
readonly abortSignal: AbortSignal,
|
||||
readonly deadline: Date | undefined,
|
||||
) {}
|
||||
|
||||
value<T = unknown>(key: string): T | undefined {
|
||||
return this.parent.value(key);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +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 { Duration } from 'luxon';
|
||||
import { Contexts } from './Contexts';
|
||||
|
||||
describe('Contexts', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('can create a root', () => {
|
||||
const ctx = Contexts.root();
|
||||
expect(ctx.abortSignal).toBeDefined();
|
||||
expect(ctx.deadline).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAbort', () => {
|
||||
it('works for controllers', () => {
|
||||
const controller = new AbortController();
|
||||
const parent = Contexts.root();
|
||||
const child = Contexts.withAbort(parent, controller);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
controller.abort();
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('works for signals', () => {
|
||||
const controller = new AbortController();
|
||||
const parent = Contexts.root();
|
||||
const child = Contexts.withAbort(parent, controller.signal);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
controller.abort();
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTimeoutDuration', () => {
|
||||
it('works', () => {
|
||||
jest.useFakeTimers();
|
||||
const parent = Contexts.root();
|
||||
const child = Contexts.withTimeoutDuration(
|
||||
parent,
|
||||
Duration.fromMillis(200),
|
||||
);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
jest.advanceTimersByTime(100);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
jest.advanceTimersByTime(101);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTimeoutMillis', () => {
|
||||
it('works', () => {
|
||||
jest.useFakeTimers();
|
||||
const parent = Contexts.root();
|
||||
const child = Contexts.withTimeoutMillis(parent, 200);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
jest.advanceTimersByTime(100);
|
||||
expect(child.abortSignal.aborted).toBe(false);
|
||||
jest.advanceTimersByTime(101);
|
||||
expect(child.abortSignal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue', () => {
|
||||
it('works', () => {
|
||||
const parent = Contexts.root();
|
||||
const child = Contexts.withValue(parent, 'k', 'v');
|
||||
expect(child.value('k')).toBe('v');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,114 +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 { Duration } from 'luxon';
|
||||
import { AbortContext } from './AbortContext';
|
||||
import { RootContext } from './RootContext';
|
||||
import { Context } from './types';
|
||||
import { ValueContext } from './ValueContext';
|
||||
|
||||
/**
|
||||
* Common context decorators.
|
||||
*
|
||||
* @alpha
|
||||
* @deprecated This class is not used in the new Backend system, so it is going to be removed in a near future.
|
||||
*/
|
||||
export class Contexts {
|
||||
/**
|
||||
* Creates a root context.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This should normally only be called near the root of an application. The
|
||||
* created context is meant to be passed down into deeper levels, which may or
|
||||
* may not make derived contexts out of it.
|
||||
*/
|
||||
static root(): Context {
|
||||
return new RootContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a derived context, which signals to abort operations either when
|
||||
* any parent context signals, or when the given source is aborted.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If the parent context was already aborted, then it is returned as-is.
|
||||
*
|
||||
* If the given source was already aborted, then a new already-aborted context
|
||||
* is returned.
|
||||
*
|
||||
* @param parentCtx - A parent context that shall be used as a base
|
||||
* @param source - An abort controller or signal that you intend to perhaps
|
||||
* trigger at some later point in time.
|
||||
* @returns A new {@link Context}
|
||||
*/
|
||||
static withAbort(
|
||||
parentCtx: Context,
|
||||
source: AbortController | AbortSignal,
|
||||
): Context {
|
||||
return 'aborted' in source
|
||||
? AbortContext.forSignal(parentCtx, source)
|
||||
: AbortContext.forController(parentCtx, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a derived context, which signals to abort operations either when
|
||||
* any parent context signals, or when the given amount of time has passed.
|
||||
* This may affect the deadline.
|
||||
*
|
||||
* @param parentCtx - A parent context that shall be used as a base
|
||||
* @param timeout - The duration of time, after which the derived context will
|
||||
* signal to abort.
|
||||
* @returns A new {@link Context}
|
||||
*/
|
||||
static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context {
|
||||
return AbortContext.forTimeoutMillis(parentCtx, timeout.as('milliseconds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a derived context, which signals to abort operations either when
|
||||
* any parent context signals, or when the given amount of time has passed.
|
||||
* This may affect the deadline.
|
||||
*
|
||||
* @param parentCtx - A parent context that shall be used as a base
|
||||
* @param timeout - The number of milliseconds, after which the derived
|
||||
* context will signal to abort.
|
||||
* @returns A new {@link Context}
|
||||
*/
|
||||
static withTimeoutMillis(parentCtx: Context, timeout: number): Context {
|
||||
return AbortContext.forTimeoutMillis(parentCtx, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a derived context, which has a specific key-value pair set as well
|
||||
* as all key-value pairs that were set in the original context.
|
||||
*
|
||||
* @param parentCtx - A parent context that shall be used as a base
|
||||
* @param key - The key of the value to set
|
||||
* @param value - The value, or a function that accepts the previous value (or
|
||||
* undefined if not set yet) and computes the new value
|
||||
* @returns A new {@link Context}
|
||||
*/
|
||||
static withValue(
|
||||
parentCtx: Context,
|
||||
key: string,
|
||||
value: unknown | ((previous: unknown | undefined) => unknown),
|
||||
): Context {
|
||||
const v = typeof value === 'function' ? value(parentCtx.value(key)) : value;
|
||||
return ValueContext.forConstantValue(parentCtx, key, v);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +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 { RootContext } from './RootContext';
|
||||
|
||||
describe('RootContext', () => {
|
||||
it('returns empty values', async () => {
|
||||
const ctx = new RootContext();
|
||||
expect(ctx.abortSignal).toBeDefined();
|
||||
expect(ctx.abortSignal.aborted).toBe(false);
|
||||
expect(ctx.deadline).toBeUndefined();
|
||||
expect(ctx.value('a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +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 { Context } from './types';
|
||||
|
||||
/**
|
||||
* Since the root context can never abort, and since nobody is ever meant to
|
||||
* dispatch events through it, we can use a static fake instance for efficiency.
|
||||
*
|
||||
* The reason that this was initially made for the root context is that due to
|
||||
* the way that we always chain contexts off of it, sometimes a huge number of
|
||||
* listeners want to add themselves to something that effectively never can be
|
||||
* aborted in the first place. This triggered warnings that the max listeners
|
||||
* limit was exceeded.
|
||||
*/
|
||||
class FakeAbortSignal implements AbortSignal {
|
||||
readonly aborted = false;
|
||||
readonly reason = undefined;
|
||||
onabort() {}
|
||||
throwIfAborted() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty root context.
|
||||
*/
|
||||
export class RootContext implements Context {
|
||||
readonly abortSignal = new FakeAbortSignal();
|
||||
readonly deadline = undefined;
|
||||
value<T = unknown>(_key: string): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +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 { RootContext } from './RootContext';
|
||||
import { ValueContext } from './ValueContext';
|
||||
|
||||
describe('ValueContext', () => {
|
||||
it('returns its own values, or delegates to the parent', async () => {
|
||||
const root = new RootContext();
|
||||
const a = ValueContext.forConstantValue(root, 'a', 1);
|
||||
const b = ValueContext.forConstantValue(a, 'x', 2);
|
||||
const c = ValueContext.forConstantValue(b, 'a', 3);
|
||||
const d = ValueContext.forConstantValue(c, 'b', 4);
|
||||
|
||||
expect(a.value('a')).toBe(1);
|
||||
expect(a.value('b')).toBeUndefined();
|
||||
expect(a.value('x')).toBeUndefined();
|
||||
|
||||
expect(b.value('a')).toBe(1);
|
||||
expect(b.value('b')).toBeUndefined();
|
||||
expect(b.value('x')).toBe(2);
|
||||
|
||||
expect(c.value('a')).toBe(3);
|
||||
expect(c.value('b')).toBeUndefined();
|
||||
expect(c.value('x')).toBe(2);
|
||||
|
||||
expect(d.value('a')).toBe(3);
|
||||
expect(d.value('b')).toBe(4);
|
||||
expect(d.value('x')).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +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 { Context } from './types';
|
||||
|
||||
/**
|
||||
* A context that just holds a single value, and delegates the rest to its
|
||||
* parent.
|
||||
*/
|
||||
export class ValueContext implements Context {
|
||||
static forConstantValue(ctx: Context, key: string, value: unknown): Context {
|
||||
return new ValueContext(ctx, key, value);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly _parent: Context,
|
||||
private readonly _key: string,
|
||||
private readonly _value: unknown,
|
||||
) {}
|
||||
|
||||
get abortSignal(): AbortSignal {
|
||||
return this._parent.abortSignal;
|
||||
}
|
||||
|
||||
get deadline(): Date | undefined {
|
||||
return this._parent.deadline;
|
||||
}
|
||||
|
||||
value<T = unknown>(key: string): T | undefined {
|
||||
return key === this._key ? (this._value as T) : this._parent.value(key);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +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.
|
||||
*/
|
||||
|
||||
export { Contexts } from './Contexts';
|
||||
export type { Context } from './types';
|
||||
@@ -1,44 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A context that is meant to be passed as a ctx variable down the call chain,
|
||||
* to pass along scoped information and abort signals.
|
||||
*
|
||||
* @alpha
|
||||
* @deprecated This type is not used in the new Backend system, so it is going to be removed in a near future.
|
||||
*/
|
||||
export interface Context {
|
||||
/**
|
||||
* Returns an abort signal that triggers when the current context or any of
|
||||
* its parents signal for it.
|
||||
*/
|
||||
readonly abortSignal: AbortSignal;
|
||||
|
||||
/**
|
||||
* The point in time when the current context shall time out and abort, if
|
||||
* applicable.
|
||||
*/
|
||||
readonly deadline: Date | undefined;
|
||||
|
||||
/**
|
||||
* Attempts to get a stored value by key from the context.
|
||||
*
|
||||
* @param key - The key of the value to get
|
||||
* @returns The associated value, or undefined if not set
|
||||
*/
|
||||
value<T = unknown>(key: string): T | undefined;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Find all active hot module APIs of all ancestors of a module, including the module itself
|
||||
function findAllAncestors(_module: NodeModule): NodeModule[] {
|
||||
const ancestors = new Array<NodeModule>();
|
||||
const parentIds = new Set<string | number>();
|
||||
|
||||
function add(id: string | number, m: NodeModule) {
|
||||
if (parentIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
parentIds.add(id);
|
||||
ancestors.push(m);
|
||||
|
||||
for (const parentId of (m as any).parents) {
|
||||
const parent = require.cache[parentId];
|
||||
if (parent) {
|
||||
add(parentId, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(_module.id, _module);
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* useHotCleanup allows cleanup of ongoing effects when a module is
|
||||
* hot-reloaded during development. The cleanup function will be called
|
||||
* whenever the module itself or any of its parent modules is hot-reloaded.
|
||||
*
|
||||
* Useful for cleaning intervals, timers, requests etc
|
||||
*
|
||||
* @public
|
||||
* @deprecated Hot module reloading is no longer supported for backends.
|
||||
* @example
|
||||
* ```ts
|
||||
* const intervalId = setInterval(doStuff, 1000);
|
||||
* useHotCleanup(module, () => clearInterval(intervalId));
|
||||
* ```
|
||||
* @param _module - Reference to the current module where you invoke the fn
|
||||
* @param cancelEffect - Fn that cleans up the ongoing effects
|
||||
*/
|
||||
export function useHotCleanup(_module: NodeModule, cancelEffect: () => void) {
|
||||
if (_module.hot) {
|
||||
const ancestors = findAllAncestors(_module);
|
||||
let cancelled = false;
|
||||
|
||||
const handler = () => {
|
||||
if (!cancelled) {
|
||||
cancelled = true;
|
||||
cancelEffect();
|
||||
}
|
||||
};
|
||||
|
||||
for (const m of ancestors) {
|
||||
m.hot?.addDisposeHandler(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CURRENT_HOT_MEMOIZE_INDEX_KEY = 'backstage.io/hmr-memoize-key';
|
||||
|
||||
/**
|
||||
* Memoizes a generated value across hot-module reloads. This is useful for
|
||||
* stateful parts of the backend, e.g. to retain a database.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Hot module reloading is no longer supported for backends.
|
||||
* @example
|
||||
* ```ts
|
||||
* const db = useHotMemoize(module, () => createDB(dbParams));
|
||||
* ```
|
||||
*
|
||||
* **NOTE:** Do not use inside conditionals or loops,
|
||||
* same rules as for hooks apply (https://reactjs.org/docs/hooks-rules.html)
|
||||
*
|
||||
* @param _module - Reference to the current module where you invoke the fn
|
||||
* @param valueFactory - Fn that returns the value you want to memoize
|
||||
*/
|
||||
export function useHotMemoize<T>(
|
||||
_module: NodeModule,
|
||||
valueFactory: () => T,
|
||||
): T {
|
||||
if (!_module.hot) {
|
||||
return valueFactory();
|
||||
}
|
||||
|
||||
// When starting blank, reset the counter
|
||||
if (!_module.hot.data?.[CURRENT_HOT_MEMOIZE_INDEX_KEY]) {
|
||||
for (const ancestor of findAllAncestors(_module)) {
|
||||
ancestor.hot?.addDisposeHandler(data => {
|
||||
data[CURRENT_HOT_MEMOIZE_INDEX_KEY] = 1;
|
||||
});
|
||||
}
|
||||
|
||||
_module.hot.data = {
|
||||
..._module.hot.data,
|
||||
[CURRENT_HOT_MEMOIZE_INDEX_KEY]: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Store data per module, based on the order of the code invocation
|
||||
const index = _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY]++;
|
||||
const value = _module.hot.data[index] ?? valueFactory();
|
||||
|
||||
// Always add a handler that, upon a HMR event, reinstates the value.
|
||||
_module.hot.addDisposeHandler(data => {
|
||||
data[index] = value;
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { HostDiscovery as _HostDiscovery } from '../../../backend-defaults/src/entrypoints/discovery/HostDiscovery';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { CacheManager as _CacheManager } from '../../../backend-defaults/src/entrypoints/cache/CacheManager';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { type CacheManagerOptions as _CacheManagerOptions } from '../../../backend-defaults/src/entrypoints/cache/types';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
DatabaseManager as _DatabaseManager,
|
||||
type DatabaseManagerOptions as _DatabaseManagerOptions,
|
||||
} from '../../../backend-defaults/src/entrypoints/database/DatabaseManager';
|
||||
|
||||
import {
|
||||
DiscoveryService,
|
||||
CacheService,
|
||||
CacheServiceOptions,
|
||||
CacheServiceSetOptions,
|
||||
DatabaseService as _PluginDatabaseManager,
|
||||
isDatabaseConflictError as _isDatabaseConflictError,
|
||||
resolvePackagePath as _resolvePackagePath,
|
||||
resolveSafeChildPath as _resolveSafeChildPath,
|
||||
isChildPath as _isChildPath,
|
||||
LifecycleService,
|
||||
PluginMetadataService,
|
||||
DatabaseService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
export * from './hot';
|
||||
export * from './config';
|
||||
export * from './scm';
|
||||
export * from './tokens';
|
||||
export * from './logging';
|
||||
export * from './service';
|
||||
export * from './middleware';
|
||||
export * from './util';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `DiscoveryService` from `@backstage/backend-plugin-api` instead
|
||||
*/
|
||||
export type PluginEndpointDiscovery = DiscoveryService;
|
||||
|
||||
/**
|
||||
* HostDiscovery is a basic PluginEndpointDiscovery implementation
|
||||
* that can handle plugins that are hosted in a single or multiple deployments.
|
||||
*
|
||||
* The deployment may be scaled horizontally, as long as the external URL
|
||||
* is the same for all instances. However, internal URLs will always be
|
||||
* resolved to the same host, so there won't be any balancing of internal traffic.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Please import from `@backstage/backend-defaults/discovery` instead.
|
||||
*/
|
||||
export class HostDiscovery implements DiscoveryService {
|
||||
/**
|
||||
* Creates a new HostDiscovery discovery instance by reading
|
||||
* from the `backend` config section, specifically the `.baseUrl` for
|
||||
* discovering the external URL, and the `.listen` and `.https` config
|
||||
* for the internal one.
|
||||
*
|
||||
* Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
|
||||
* eg.
|
||||
* ```yaml
|
||||
* discovery:
|
||||
* endpoints:
|
||||
* - target: https://internal.example.com/internal-catalog
|
||||
* plugins: [catalog]
|
||||
* - target: https://internal.example.com/secure/api/{{pluginId}}
|
||||
* plugins: [auth, permission]
|
||||
* - target:
|
||||
* internal: https://internal.example.com/search
|
||||
* external: https://example.com/search
|
||||
* plugins: [search]
|
||||
* ```
|
||||
*
|
||||
* The fixed base path is `/api`, meaning the default full internal
|
||||
* path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
|
||||
*/
|
||||
static fromConfig(config: Config) {
|
||||
return new HostDiscovery(_HostDiscovery.fromConfig(config));
|
||||
}
|
||||
|
||||
private constructor(private readonly impl: _HostDiscovery) {}
|
||||
|
||||
async getBaseUrl(pluginId: string): Promise<string> {
|
||||
return this.impl.getBaseUrl(pluginId);
|
||||
}
|
||||
|
||||
async getExternalBaseUrl(pluginId: string): Promise<string> {
|
||||
return this.impl.getExternalBaseUrl(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
|
||||
* that assumes that all plugins are hosted in a single deployment.
|
||||
*
|
||||
* The deployment may be scaled horizontally, as long as the external URL
|
||||
* is the same for all instances. However, internal URLs will always be
|
||||
* resolved to the same host, so there won't be any balancing of internal traffic.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `HostDiscovery` from `@backstage/backend-defaults/discovery` instead
|
||||
*/
|
||||
export { HostDiscovery as SingleHostDiscovery };
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `CacheManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export class CacheManager {
|
||||
/**
|
||||
* Creates a new {@link CacheManager} instance by reading from the `backend`
|
||||
* config section, specifically the `.cache` key.
|
||||
*
|
||||
* @param config - The loaded application configuration.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: RootConfigService,
|
||||
options: CacheManagerOptions = {},
|
||||
): CacheManager {
|
||||
return new CacheManager(_CacheManager.fromConfig(config, options));
|
||||
}
|
||||
|
||||
private constructor(private readonly _impl: _CacheManager) {}
|
||||
|
||||
forPlugin(pluginId: string): PluginCacheManager {
|
||||
return {
|
||||
getClient: options => {
|
||||
const result = this._impl.forPlugin(pluginId);
|
||||
return options ? result.withOptions(options) : result;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `CacheManagerOptions` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export type CacheManagerOptions = _CacheManagerOptions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `PluginCacheManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export type PluginCacheManager = {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `CacheService` from the `@backstage/backend-plugin-api` package instead
|
||||
*/
|
||||
export type CacheClient = CacheService;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `CacheServiceSetOptions` from the `@backstage/backend-plugin-api` package instead
|
||||
*/
|
||||
export type CacheClientSetOptions = CacheServiceSetOptions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `CacheServiceOptions` from the `@backstage/backend-plugin-api` package instead
|
||||
*/
|
||||
export type CacheClientOptions = CacheServiceOptions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `DatabaseManager` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export class DatabaseManager implements LegacyRootDatabaseService {
|
||||
private constructor(
|
||||
private readonly _databaseManager: _DatabaseManager,
|
||||
private readonly logger?: LoggerService,
|
||||
) {}
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
migrations?: DatabaseService['migrations'];
|
||||
logger?: LoggerService;
|
||||
},
|
||||
): DatabaseManager {
|
||||
const _databaseManager = _DatabaseManager.fromConfig(config, options);
|
||||
return new DatabaseManager(_databaseManager, options?.logger);
|
||||
}
|
||||
|
||||
forPlugin(
|
||||
pluginId: string,
|
||||
deps?:
|
||||
| { lifecycle: LifecycleService; pluginMetadata: PluginMetadataService }
|
||||
| undefined,
|
||||
): PluginDatabaseManager {
|
||||
const logger: LoggerService = this.logger ?? {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
child() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const lifecycle: LifecycleService = deps?.lifecycle ?? {
|
||||
addShutdownHook() {},
|
||||
addStartupHook() {},
|
||||
};
|
||||
return this._databaseManager.forPlugin(pluginId, { logger, lifecycle });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `DatabaseManagerOptions` from the `@backstage/backend-defaults` package instead
|
||||
*/
|
||||
export type DatabaseManagerOptions = _DatabaseManagerOptions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `DatabaseService` from the `@backstage/backend-plugin-api` package instead
|
||||
*/
|
||||
export type PluginDatabaseManager = _PluginDatabaseManager;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `DatabaseManager` from `@backstage/backend-defaults/database` instead, or migrate to the new backend system and use `coreServices.database`
|
||||
*/
|
||||
export type LegacyRootDatabaseService = {
|
||||
forPlugin(pluginId: string): DatabaseService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `isDatabaseConflictError` function from the `@backstage/backend-plugin-api` package instead.
|
||||
*/
|
||||
export const isDatabaseConflictError = _isDatabaseConflictError;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `resolvePackagePath` function from the `@backstage/backend-plugin-api` package instead.
|
||||
*/
|
||||
export const resolvePackagePath = _resolvePackagePath;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `resolveSafeChildPath` function from the `@backstage/backend-plugin-api` package instead.
|
||||
*/
|
||||
export const resolveSafeChildPath = _resolveSafeChildPath;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This function is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `isChildPath` function from the `@backstage/cli-common` package instead.
|
||||
*/
|
||||
export const isChildPath = _isChildPath;
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { WinstonLogger } from '../../../../backend-defaults/src/entrypoints/rootLogger/WinstonLogger';
|
||||
import { merge } from 'lodash';
|
||||
import * as winston from 'winston';
|
||||
import { format, LoggerOptions } from 'winston';
|
||||
import { TransformableInfo } from 'logform';
|
||||
import { setRootLogger } from './globalLoggers';
|
||||
|
||||
const getRedacter = (() => {
|
||||
let redacter: ReturnType<typeof WinstonLogger.redacter> | undefined =
|
||||
undefined;
|
||||
return () => {
|
||||
if (!redacter) {
|
||||
redacter = WinstonLogger.redacter();
|
||||
}
|
||||
return redacter;
|
||||
};
|
||||
})();
|
||||
|
||||
export const setRootLoggerRedactionList = (
|
||||
redactions: Iterable<string>,
|
||||
): void => {
|
||||
getRedacter().add(redactions);
|
||||
};
|
||||
|
||||
/**
|
||||
* A winston formatting function that finds occurrences of filteredKeys
|
||||
* and replaces them with the corresponding identifier.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This utility is being deprecated along with the {@link https://github.com/backstage/backstage/issues/24493 |legacy backend system}.
|
||||
* Migrate your {@link https://backstage.io/docs/backend-system/building-backends/migrating | backend} and {@link https://backstage.io/docs/backend-system/building-plugins-and-modules/migrating | plugin} to the new system and use the {@link https://github.com/backstage/backstage/pull/24730 | RedactionsService} for customization instead.
|
||||
*/
|
||||
export function redactWinstonLogLine(
|
||||
info: winston.Logform.TransformableInfo,
|
||||
): winston.Logform.TransformableInfo {
|
||||
return getRedacter().format.transform(
|
||||
info,
|
||||
) as winston.Logform.TransformableInfo;
|
||||
}
|
||||
|
||||
const colorizer = format.colorize();
|
||||
|
||||
// NOTE: This is a copy of the WinstonLogger.colorFormat to avoid a circular dependency
|
||||
/**
|
||||
* Creates a pretty printed winston log formatter.
|
||||
*
|
||||
* @public
|
||||
* @deprecated As we are going to deprecate the legacy backend, this formatter utility will be removed in the future.
|
||||
* If you need to format logs in the new system, please use the `WinstonLogger.colorFormat()` from `@backstage/backend-app-api` instead.
|
||||
*/
|
||||
export const coloredFormat = format.combine(
|
||||
format.timestamp(),
|
||||
format.colorize({
|
||||
colors: {
|
||||
timestamp: 'dim',
|
||||
prefix: 'blue',
|
||||
field: 'cyan',
|
||||
debug: 'grey',
|
||||
},
|
||||
}),
|
||||
format.printf((info: TransformableInfo) => {
|
||||
const { timestamp, level, message, plugin, service, ...fields } = info;
|
||||
const prefix = plugin || service;
|
||||
const timestampColor = colorizer.colorize('timestamp', timestamp);
|
||||
const prefixColor = colorizer.colorize('prefix', prefix);
|
||||
|
||||
const extraFields = Object.entries(fields)
|
||||
.map(
|
||||
([key, value]) => `${colorizer.colorize('field', `${key}`)}=${value}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return `${timestampColor} ${prefixColor} ${level} ${message} ${extraFields}`;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a default "root" logger. This also calls {@link setRootLogger} under
|
||||
* the hood.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is the logger instance that will be the foundation for all other logger
|
||||
* instances passed to plugins etc, in a given backend.
|
||||
*
|
||||
* @public
|
||||
* @deprecated As we are going to deprecate the legacy backend, this function will be removed in the future.
|
||||
* If you need to create the root logger in the new system, please check out this documentation:
|
||||
* https://backstage.io/docs/backend-system/core-services/logger
|
||||
*/
|
||||
export function createRootLogger(
|
||||
options: winston.LoggerOptions = {},
|
||||
env = process.env,
|
||||
): winston.Logger {
|
||||
const logger = winston
|
||||
.createLogger(
|
||||
merge<LoggerOptions, LoggerOptions>(
|
||||
{
|
||||
level: env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
getRedacter().format,
|
||||
env.NODE_ENV === 'production'
|
||||
? winston.format.json()
|
||||
: WinstonLogger.colorFormat(),
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
silent: env.JEST_WORKER_ID !== undefined && !env.LOG_LEVEL,
|
||||
}),
|
||||
],
|
||||
},
|
||||
options,
|
||||
),
|
||||
)
|
||||
.child({ service: 'backstage' });
|
||||
|
||||
setRootLogger(logger);
|
||||
|
||||
return logger;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as winston from 'winston';
|
||||
import { createRootLogger } from './createRootLogger';
|
||||
|
||||
/**
|
||||
* A logger that just throws away all messages.
|
||||
*
|
||||
* @public
|
||||
* @deprecated As we are going to deprecate the legacy backend, this function will be removed in the future.
|
||||
* If you need to mock the root logger in the new system, please use `mockServices.logger.mock()` from `@backstage/test-utils` instead.
|
||||
*/
|
||||
export function getVoidLogger(): winston.Logger {
|
||||
return winston.createLogger({
|
||||
transports: [new winston.transports.Console({ silent: true })],
|
||||
});
|
||||
}
|
||||
|
||||
let rootLogger: winston.Logger;
|
||||
|
||||
/**
|
||||
* Gets the current root logger.
|
||||
*
|
||||
* @public
|
||||
* @deprecated As we are going to deprecate the legacy backend, this function will be removed in the future.
|
||||
* If you need to get the root logger in the new system, please check out this documentation:
|
||||
* https://backstage.io/docs/backend-system/core-services/logger
|
||||
*/
|
||||
export function getRootLogger(): winston.Logger {
|
||||
if (!rootLogger) {
|
||||
rootLogger = createRootLogger();
|
||||
}
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a completely custom default "root" logger.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is the logger instance that will be the foundation for all other logger
|
||||
* instances passed to plugins etc, in a given backend.
|
||||
*
|
||||
* Only use this if you absolutely need to make a completely custom logger.
|
||||
* Normally if you want to make light adaptations to the default logger
|
||||
* behavior, you would instead call {@link createRootLogger}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated As we are going to deprecate the legacy backend, this function will be removed in the future.
|
||||
* If you need to set the root logger in the new system, please check out this documentation:
|
||||
* https://backstage.io/docs/backend-system/core-services/logger
|
||||
*/
|
||||
export function setRootLogger(newLogger: winston.Logger) {
|
||||
rootLogger = newLogger;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { getRootLogger, getVoidLogger, setRootLogger } from './globalLoggers';
|
||||
export {
|
||||
createRootLogger,
|
||||
redactWinstonLogLine,
|
||||
coloredFormat,
|
||||
} from './createRootLogger';
|
||||
@@ -1,252 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { STATUS_CODES } from 'http';
|
||||
import createError from 'http-errors';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotModifiedError,
|
||||
ResponseError,
|
||||
} from '@backstage/errors';
|
||||
import { errorHandler } from './errorHandler';
|
||||
|
||||
describe('errorHandler', () => {
|
||||
it('gives default code and message', async () => {
|
||||
const app = express();
|
||||
app.use('/breaks', () => {
|
||||
throw new Error('some message');
|
||||
});
|
||||
app.use(errorHandler());
|
||||
|
||||
const response = await request(app).get('/breaks');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: expect.objectContaining({
|
||||
name: 'Error',
|
||||
message: 'some message',
|
||||
}),
|
||||
request: { method: 'GET', url: '/breaks' },
|
||||
response: { statusCode: 500 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not try to send the response again if its already been sent', async () => {
|
||||
const app = express();
|
||||
const mockSend = jest.fn();
|
||||
|
||||
app.use('/works_with_async_fail', (_, res) => {
|
||||
res.status(200).send('hello');
|
||||
|
||||
// mutate the response object to test the middleware.
|
||||
// it's hard to catch errors inside middleware from the outside.
|
||||
res.send = mockSend;
|
||||
throw new Error('some message');
|
||||
});
|
||||
|
||||
app.use(errorHandler());
|
||||
const response = await request(app).get('/works_with_async_fail');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('hello');
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes code from http-errors library errors', async () => {
|
||||
const app = express();
|
||||
app.use('/breaks', () => {
|
||||
throw createError(432, 'Some Message');
|
||||
});
|
||||
app.use(errorHandler());
|
||||
|
||||
const response = await request(app).get('/breaks');
|
||||
|
||||
expect(response.status).toBe(432);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
expose: true,
|
||||
name: 'BadRequestError',
|
||||
message: 'Some Message',
|
||||
status: 432,
|
||||
statusCode: 432,
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/breaks',
|
||||
},
|
||||
response: { statusCode: 432 },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles well-known error classes', async () => {
|
||||
const app = express();
|
||||
app.use('/NotModifiedError', () => {
|
||||
throw new NotModifiedError();
|
||||
});
|
||||
app.use('/InputError', () => {
|
||||
throw new InputError();
|
||||
});
|
||||
app.use('/AuthenticationError', () => {
|
||||
throw new AuthenticationError();
|
||||
});
|
||||
app.use('/NotAllowedError', () => {
|
||||
throw new NotAllowedError();
|
||||
});
|
||||
app.use('/NotFoundError', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use('/ConflictError', () => {
|
||||
throw new ConflictError();
|
||||
});
|
||||
app.use('/ResponseErrorBackstagePlugin', async (_req, _res, next) => {
|
||||
const mockedResponse = {
|
||||
status: jest.fn(() => mockedResponse),
|
||||
json: jest.fn(() => mockedResponse),
|
||||
} as unknown as jest.Mocked<express.Response>;
|
||||
|
||||
// serialize AuthenticationError in mockedResponse
|
||||
errorHandler()(
|
||||
new AuthenticationError('an error'),
|
||||
{ method: 'GET', url: '' } as express.Request,
|
||||
mockedResponse,
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
const status = mockedResponse.status.mock.calls[0][0];
|
||||
next(
|
||||
await ResponseError.fromResponse({
|
||||
headers: new Headers({
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
ok: false,
|
||||
redirected: false,
|
||||
status,
|
||||
statusText: STATUS_CODES[status]!,
|
||||
type: 'default',
|
||||
url: '',
|
||||
text: async () =>
|
||||
JSON.stringify(mockedResponse.json.mock.calls[0][0]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
app.use('/ResponseError', async (_req, _res, next) => {
|
||||
next(
|
||||
await ResponseError.fromResponse({
|
||||
headers: new Headers({
|
||||
'content-type': 'application/json',
|
||||
}),
|
||||
ok: false,
|
||||
redirected: false,
|
||||
status: 403,
|
||||
statusText: STATUS_CODES[403]!,
|
||||
type: 'default',
|
||||
url: '',
|
||||
text: async () => JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
app.use(errorHandler());
|
||||
|
||||
const r = request(app);
|
||||
expect((await r.get('/NotModifiedError')).status).toBe(304);
|
||||
expect((await r.get('/InputError')).status).toBe(400);
|
||||
expect((await r.get('/InputError')).body.error.name).toBe('InputError');
|
||||
expect((await r.get('/AuthenticationError')).status).toBe(401);
|
||||
expect((await r.get('/AuthenticationError')).body.error.name).toBe(
|
||||
'AuthenticationError',
|
||||
);
|
||||
expect((await r.get('/NotAllowedError')).status).toBe(403);
|
||||
expect((await r.get('/NotAllowedError')).body.error.name).toBe(
|
||||
'NotAllowedError',
|
||||
);
|
||||
expect((await r.get('/NotFoundError')).status).toBe(404);
|
||||
expect((await r.get('/NotFoundError')).body.error.name).toBe(
|
||||
'NotFoundError',
|
||||
);
|
||||
expect((await r.get('/ConflictError')).status).toBe(409);
|
||||
expect((await r.get('/ConflictError')).body.error.name).toBe(
|
||||
'ConflictError',
|
||||
);
|
||||
expect((await r.get('/ResponseErrorBackstagePlugin')).status).toBe(401);
|
||||
expect((await r.get('/ResponseErrorBackstagePlugin')).body.error.name).toBe(
|
||||
'ResponseError',
|
||||
);
|
||||
expect((await r.get('/ResponseError')).status).toBe(403);
|
||||
expect((await r.get('/ResponseError')).body.error.name).toBe(
|
||||
'ResponseError',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs all 500 errors', async () => {
|
||||
const app = express();
|
||||
|
||||
const mockLogger = { child: jest.fn(), error: jest.fn() };
|
||||
mockLogger.child.mockImplementation(() => mockLogger as any);
|
||||
|
||||
const thrownError = new Error('some error');
|
||||
|
||||
app.use('/breaks', () => {
|
||||
throw thrownError;
|
||||
});
|
||||
app.use(errorHandler({ logger: mockLogger as any }));
|
||||
|
||||
await request(app).get('/breaks');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Request failed with status 500',
|
||||
thrownError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log 400 errors', async () => {
|
||||
const app = express();
|
||||
|
||||
const mockLogger = { child: jest.fn(), error: jest.fn() };
|
||||
mockLogger.child.mockImplementation(() => mockLogger as any);
|
||||
|
||||
app.use('/NotFound', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use(errorHandler({ logger: mockLogger as any }));
|
||||
|
||||
await request(app).get('/NotFound');
|
||||
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('log 400 errors when logClientErrors is true', async () => {
|
||||
const app = express();
|
||||
|
||||
const mockLogger = { child: jest.fn(), error: jest.fn() };
|
||||
mockLogger.child.mockImplementation(() => mockLogger as any);
|
||||
|
||||
app.use('/NotFound', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use(errorHandler({ logger: mockLogger as any, logClientErrors: true }));
|
||||
|
||||
await request(app).get('/NotFound');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { MiddlewareFactory } from '../../../../backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory';
|
||||
import { getRootLogger } from '../logging';
|
||||
|
||||
/**
|
||||
* Options passed to the {@link errorHandler} middleware.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is being deprecated along with the {@link @backstage/backend-common#errorHandler} function.
|
||||
*/
|
||||
export type ErrorHandlerOptions = {
|
||||
/**
|
||||
* Whether error response bodies should show error stack traces or not.
|
||||
*
|
||||
* If not specified, by default shows stack traces only in development mode.
|
||||
*/
|
||||
showStackTraces?: boolean;
|
||||
|
||||
/**
|
||||
* Logger instance to log errors.
|
||||
*
|
||||
* If not specified, the root logger will be used.
|
||||
*/
|
||||
logger?: LoggerService;
|
||||
|
||||
/**
|
||||
* Whether any 4xx errors should be logged or not.
|
||||
*
|
||||
* If not specified, default to only logging 5xx errors.
|
||||
*/
|
||||
logClientErrors?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Express middleware to handle errors during request processing.
|
||||
*
|
||||
* This is commonly the very last middleware in the chain.
|
||||
*
|
||||
* Its primary purpose is not to do translation of business logic exceptions,
|
||||
* but rather to be a global catch-all for uncaught "fatal" errors that are
|
||||
* expected to result in a 500 error. However, it also does handle some common
|
||||
* error types (such as http-error exceptions) and returns the enclosed status
|
||||
* code accordingly.
|
||||
*
|
||||
* @public
|
||||
* @returns An Express error request handler
|
||||
* @deprecated Use {@link @backstage/backend-defaults/rootHttpRouter#MiddlewareFactory.create.error} instead
|
||||
*/
|
||||
export function errorHandler(
|
||||
options: ErrorHandlerOptions = {},
|
||||
): ErrorRequestHandler {
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: options.logger ?? getRootLogger(),
|
||||
}).error({
|
||||
logAllErrors: options.logClientErrors,
|
||||
showStackTraces: options.showStackTraces,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './errorHandler';
|
||||
export * from './notFoundHandler';
|
||||
export * from './requestLoggingHandler';
|
||||
export * from './statusCheckHandler';
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { notFoundHandler } from './notFoundHandler';
|
||||
|
||||
describe('notFoundHandler', () => {
|
||||
it('handles only missing routes', async () => {
|
||||
const app = express();
|
||||
app.use('/exists', (_, res) => res.status(200).end());
|
||||
app.use(notFoundHandler());
|
||||
|
||||
const existsResponse = await request(app).get('/exists');
|
||||
const doesNotExistResponse = await request(app).get('/doesNotExist');
|
||||
|
||||
expect(existsResponse.status).toBe(200);
|
||||
expect(doesNotExistResponse.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { MiddlewareFactory } from '../../../../backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { RequestHandler } from 'express';
|
||||
import { getRootLogger } from '../logging';
|
||||
|
||||
/**
|
||||
* Express middleware to handle requests for missing routes.
|
||||
*
|
||||
* Should be used as the very last handler in the chain, as it unconditionally
|
||||
* returns a 404 status.
|
||||
*
|
||||
* @public
|
||||
* @returns An Express request handler
|
||||
* @deprecated Use {@link @backstage/backend-app-api#MiddlewareFactory.create.notFound} instead
|
||||
*/
|
||||
export function notFoundHandler(): RequestHandler {
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: getRootLogger(),
|
||||
}).notFound();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import * as winston from 'winston';
|
||||
import { requestLoggingHandler } from './requestLoggingHandler';
|
||||
|
||||
describe('requestLoggingHandler', () => {
|
||||
it('emits logs for each request', async () => {
|
||||
const logger = winston.createLogger();
|
||||
jest.spyOn(logger, 'info').mockReturnValue(logger);
|
||||
|
||||
const app = express();
|
||||
app.use(requestLoggingHandler(logger));
|
||||
app.use('/exists1', (_, res) => res.status(200).end());
|
||||
app.use('/exists2', (_, res) => res.status(201).end());
|
||||
|
||||
const r = request(app);
|
||||
await r.get('/exists1');
|
||||
await r.get('/exists2');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('200'),
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('201'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { MiddlewareFactory } from '../../../../backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory';
|
||||
import { RequestHandler } from 'express';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { getRootLogger } from '../logging';
|
||||
|
||||
/**
|
||||
* Logs incoming requests.
|
||||
*
|
||||
* @public
|
||||
* @param logger - An optional logger to use. If not specified, the root logger will be used.
|
||||
* @returns An Express request handler
|
||||
* @deprecated Use {@link @backstage/backend-app-api#MiddlewareFactory.create.logging} instead
|
||||
*/
|
||||
export function requestLoggingHandler(logger?: LoggerService): RequestHandler {
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: logger ?? getRootLogger(),
|
||||
}).logging();
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { statusCheckHandler } from './statusCheckHandler';
|
||||
|
||||
describe('statusCheckHandler', () => {
|
||||
it('gives status 200 when using default', async () => {
|
||||
const app = express();
|
||||
app.use('/healthcheck', await statusCheckHandler());
|
||||
|
||||
const response = await request(app).get('/healthcheck');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
it('gives status 200 when status function returns true', async () => {
|
||||
const app = express();
|
||||
const status = { foo: 'bar' };
|
||||
const statusCheck = () => Promise.resolve(status);
|
||||
app.use('/healthcheck', await statusCheckHandler({ statusCheck }));
|
||||
|
||||
const response = await request(app).get('/healthcheck');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe(JSON.stringify(status));
|
||||
});
|
||||
|
||||
it('gives status 500 when status check throws an error', async () => {
|
||||
const app = express();
|
||||
const statusCheck = () => {
|
||||
throw Error('error!');
|
||||
};
|
||||
app.use('/healthcheck', await statusCheckHandler({ statusCheck }));
|
||||
|
||||
const response = await request(app).get('/healthcheck');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response, RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* A custom status checking function, passed to {@link statusCheckHandler} and
|
||||
* {@link createStatusCheckRouter}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Migrate to the {@link https://backstage.io/docs/backend-system/ | new backend system} and use the {@link https://backstage.io/docs/backend-system/core-services/root-health | Root Health Service} instead.
|
||||
*/
|
||||
export type StatusCheck = () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Options passed to {@link statusCheckHandler}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Migrate to the {@link https://backstage.io/docs/backend-system/ | new backend system} and use the {@link https://backstage.io/docs/backend-system/core-services/root-health | Root Health Service} instead.
|
||||
*/
|
||||
export interface StatusCheckHandlerOptions {
|
||||
/**
|
||||
* Optional status function which returns a message.
|
||||
*/
|
||||
statusCheck?: StatusCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware for status checks.
|
||||
*
|
||||
* This is commonly used to implement healthcheck and readiness routes.
|
||||
*
|
||||
* @public
|
||||
* @param options - An optional configuration object.
|
||||
* @returns An Express error request handler
|
||||
* @deprecated Migrate to the {@link https://backstage.io/docs/backend-system/ | new backend system} and use the {@link https://backstage.io/docs/backend-system/core-services/root-health | Root Health Service} instead.
|
||||
*/
|
||||
export async function statusCheckHandler(
|
||||
options: StatusCheckHandlerOptions = {},
|
||||
): Promise<RequestHandler> {
|
||||
const statusCheck: StatusCheck = options.statusCheck
|
||||
? options.statusCheck
|
||||
: () => Promise.resolve({ status: 'ok' });
|
||||
|
||||
return async (_request: Request, response: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = await statusCheck();
|
||||
response.status(200).json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
jest.mock('isomorphic-git');
|
||||
jest.mock('isomorphic-git/http/node');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
import * as isomorphic from 'isomorphic-git';
|
||||
import { Git } from './git';
|
||||
import http from 'isomorphic-git/http/node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
describe('Git', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('should call isomorphic-git add with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const filepath = 'mockfile/path';
|
||||
|
||||
await git.add({ dir, filepath });
|
||||
|
||||
expect(isomorphic.add).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
filepath,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRemote', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const remote = 'origin';
|
||||
const url = 'git@github.com/something/sads';
|
||||
const force = true;
|
||||
|
||||
await git.addRemote({ dir, remote, url, force });
|
||||
|
||||
expect(isomorphic.addRemote).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
remote,
|
||||
url,
|
||||
force,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should call isomorphic-git remove with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const filepath = 'mockfile/path';
|
||||
|
||||
await git.remove({ dir, filepath });
|
||||
|
||||
expect(isomorphic.remove).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
filepath,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRemote', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const remote = 'origin';
|
||||
|
||||
await git.deleteRemote({ dir, remote });
|
||||
|
||||
expect(isomorphic.deleteRemote).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
remote,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const ref = 'master';
|
||||
|
||||
await git.checkout({ dir, ref });
|
||||
|
||||
expect(isomorphic.checkout).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
ref,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const ref = 'master';
|
||||
|
||||
await git.branch({ dir, ref });
|
||||
|
||||
expect(isomorphic.branch).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
ref,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commit', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const git = Git.fromAuth({});
|
||||
const dir = 'mockdirectory';
|
||||
const message = 'Inital Commit';
|
||||
const author = {
|
||||
name: 'author',
|
||||
email: 'test@backstage.io',
|
||||
};
|
||||
const committer = {
|
||||
name: 'comitter',
|
||||
email: 'test@backstage.io',
|
||||
};
|
||||
|
||||
await git.commit({ dir, message, author, committer });
|
||||
|
||||
expect(isomorphic.commit).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
message,
|
||||
author,
|
||||
committer,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clone', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const url = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.clone({ url, dir });
|
||||
|
||||
expect(isomorphic.clone).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
url,
|
||||
dir,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
|
||||
const url = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
token: 'test',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.clone({ url, dir });
|
||||
|
||||
expect(isomorphic.clone).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
url,
|
||||
dir,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
Authorization: 'Bearer test',
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass a function that returns the authorization as the onAuth handler when username and password are specified', async () => {
|
||||
const url = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.clone({ url, dir });
|
||||
|
||||
const { onAuth } = (
|
||||
isomorphic.clone as unknown as jest.Mock<(typeof isomorphic)['clone']>
|
||||
).mock.calls[0][0]!;
|
||||
|
||||
expect(onAuth()).toEqual(auth);
|
||||
});
|
||||
|
||||
it('should pass the provided callback as the onAuth handler when on auth is specified', async () => {
|
||||
const url = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'from',
|
||||
password: 'callback',
|
||||
};
|
||||
|
||||
const git = Git.fromAuth({ onAuth: () => auth });
|
||||
|
||||
await git.clone({ url, dir });
|
||||
|
||||
const { onAuth } = (
|
||||
isomorphic.clone as unknown as jest.Mock<(typeof isomorphic)['clone']>
|
||||
).mock.calls[0][0]!;
|
||||
|
||||
expect(onAuth()).toEqual(auth);
|
||||
});
|
||||
|
||||
it('should propagate the data from the error handler', async () => {
|
||||
const url = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
(isomorphic.clone as jest.Mock).mockImplementation(() => {
|
||||
const error: Error & { data?: unknown } = new Error('mock error');
|
||||
error.data = { some: 'more information here' };
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(git.clone({ url, dir })).rejects.toThrow(
|
||||
'more information here',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentBranch', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const fullName = true;
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.currentBranch({ dir, fullName });
|
||||
|
||||
expect(isomorphic.currentBranch).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
fullname: true,
|
||||
});
|
||||
|
||||
await git.currentBranch({ dir });
|
||||
|
||||
expect(isomorphic.currentBranch).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
fullname: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const remote = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.fetch({ remote, dir, tags: true });
|
||||
|
||||
expect(isomorphic.fetch).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
remote,
|
||||
dir,
|
||||
tags: true,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
|
||||
const remote = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
token: 'test',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.fetch({ remote, dir });
|
||||
|
||||
expect(isomorphic.fetch).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
remote,
|
||||
dir,
|
||||
tags: false,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
Authorization: 'Bearer test',
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass a function that returns the authorization as the onAuth handler', async () => {
|
||||
const remote = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.fetch({ remote, dir });
|
||||
|
||||
const { onAuth } = (
|
||||
isomorphic.fetch as unknown as jest.Mock<(typeof isomorphic)['fetch']>
|
||||
).mock.calls[0][0]!;
|
||||
|
||||
expect(onAuth()).toEqual(auth);
|
||||
});
|
||||
|
||||
it('should propagate the data from the error handler', async () => {
|
||||
const remote = 'http://github.com/some/repo';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
(isomorphic.fetch as jest.Mock).mockImplementation(() => {
|
||||
const error: Error & { data?: unknown } = new Error('mock error');
|
||||
error.data = { some: 'more information here' };
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(git.fetch({ remote, dir })).rejects.toThrow(
|
||||
'more information here',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const defaultBranch = 'master';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.init({ dir, defaultBranch });
|
||||
|
||||
expect(isomorphic.init).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
defaultBranch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const author = {
|
||||
name: 'author',
|
||||
email: 'test@backstage.io',
|
||||
};
|
||||
const committer = {
|
||||
name: 'comitter',
|
||||
email: 'test@backstage.io',
|
||||
};
|
||||
const theirs = 'master';
|
||||
const ours = 'production';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.merge({ dir, theirs, ours, author, committer });
|
||||
|
||||
expect(isomorphic.merge).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
ours,
|
||||
theirs,
|
||||
author,
|
||||
committer,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('push', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const remote = 'origin';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
const remoteRef = 'master';
|
||||
const force = true;
|
||||
|
||||
await git.push({ dir, remote, remoteRef, force });
|
||||
|
||||
expect(isomorphic.push).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
remote,
|
||||
dir,
|
||||
remoteRef,
|
||||
force,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
|
||||
const remote = 'origin';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
token: 'test',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
const remoteRef = 'master';
|
||||
const force = true;
|
||||
|
||||
await git.push({ dir, remote, remoteRef, force });
|
||||
|
||||
expect(isomorphic.push).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
remote,
|
||||
dir,
|
||||
remoteRef,
|
||||
force,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
Authorization: 'Bearer test',
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isomorphic-git with remoteRef parameter', async () => {
|
||||
const remote = 'origin';
|
||||
const remoteRef = 'refs/for/master';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
|
||||
await git.push({ dir, remote, remoteRef });
|
||||
|
||||
expect(isomorphic.push).toHaveBeenCalledWith({
|
||||
fs,
|
||||
http,
|
||||
remote,
|
||||
remoteRef,
|
||||
dir,
|
||||
onProgress: expect.any(Function),
|
||||
headers: {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
},
|
||||
onAuth: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass a function that returns the authorization as the onAuth handler', async () => {
|
||||
const remote = 'origin';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
const remoteRef = 'master';
|
||||
const force = true;
|
||||
|
||||
await git.push({ remote, dir, remoteRef, force });
|
||||
|
||||
const { onAuth } = (
|
||||
isomorphic.push as unknown as jest.Mock<(typeof isomorphic)['push']>
|
||||
).mock.calls[0][0]!;
|
||||
|
||||
expect(onAuth()).toEqual(auth);
|
||||
});
|
||||
|
||||
it('should propagate the data from the error handler', async () => {
|
||||
const remote = 'origin';
|
||||
const dir = '/some/mock/dir';
|
||||
const auth = {
|
||||
username: 'blob',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const git = Git.fromAuth(auth);
|
||||
const remoteRef = 'master';
|
||||
const force = true;
|
||||
|
||||
(isomorphic.push as jest.Mock).mockImplementation(() => {
|
||||
const error: Error & { data?: unknown } = new Error('mock error');
|
||||
error.data = { some: 'more information here' };
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(git.push({ remote, dir, remoteRef, force })).rejects.toThrow(
|
||||
'more information here',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCommit', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const sha = 'as43bd7';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.readCommit({ dir, sha });
|
||||
|
||||
expect(isomorphic.readCommit).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
oid: sha,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRef', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const ref = 'as43bd7';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.resolveRef({ dir, ref });
|
||||
|
||||
expect(isomorphic.resolveRef).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
ref,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('log', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const ref = 'as43bd7';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.log({ dir, ref });
|
||||
|
||||
expect(isomorphic.log).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
ref,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,357 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import git, {
|
||||
ProgressCallback,
|
||||
MergeResult,
|
||||
ReadCommitResult,
|
||||
AuthCallback,
|
||||
} from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/node';
|
||||
import fs from 'fs-extra';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
function isAuthCallbackOptions(
|
||||
options: StaticAuthOptions | AuthCallbackOptions,
|
||||
): options is AuthCallbackOptions {
|
||||
return 'onAuth' in options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure static credential for authentication
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
*/
|
||||
export type StaticAuthOptions = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure an authentication callback that can provide credentials on demand
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
*/
|
||||
export type AuthCallbackOptions = {
|
||||
onAuth: AuthCallback;
|
||||
logger?: LoggerService;
|
||||
};
|
||||
|
||||
/*
|
||||
provider username password
|
||||
Azure 'notempty' token
|
||||
Bitbucket Cloud 'x-token-auth' token
|
||||
Bitbucket Server username password or token
|
||||
GitHub 'x-access-token' token
|
||||
GitLab 'oauth2' token
|
||||
|
||||
From : https://isomorphic-git.org/docs/en/onAuth with fix for GitHub
|
||||
|
||||
Or token provided as `token` for Bearer auth header
|
||||
instead of Basic Auth (e.g., Bitbucket Server).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A convenience wrapper around the `isomorphic-git` library.
|
||||
* @public
|
||||
* @deprecated This class is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
*/
|
||||
|
||||
export class Git {
|
||||
private readonly headers: {
|
||||
[x: string]: string;
|
||||
};
|
||||
|
||||
private constructor(
|
||||
private readonly config: {
|
||||
onAuth: AuthCallback;
|
||||
token?: string;
|
||||
logger?: LoggerService;
|
||||
},
|
||||
) {
|
||||
this.onAuth = config.onAuth;
|
||||
|
||||
this.headers = {
|
||||
'user-agent': 'git/@isomorphic-git',
|
||||
...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async add(options: { dir: string; filepath: string }): Promise<void> {
|
||||
const { dir, filepath } = options;
|
||||
this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`);
|
||||
|
||||
return git.add({ fs, dir, filepath });
|
||||
}
|
||||
|
||||
async addRemote(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
url: string;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const { dir, url, remote, force } = options;
|
||||
this.config.logger?.info(
|
||||
`Creating new remote {dir=${dir},remote=${remote},url=${url}}`,
|
||||
);
|
||||
return git.addRemote({ fs, dir, remote, url, force });
|
||||
}
|
||||
|
||||
async deleteRemote(options: { dir: string; remote: string }): Promise<void> {
|
||||
const { dir, remote } = options;
|
||||
this.config.logger?.info(`Deleting remote {dir=${dir},remote=${remote}}`);
|
||||
return git.deleteRemote({ fs, dir, remote });
|
||||
}
|
||||
|
||||
async checkout(options: { dir: string; ref: string }): Promise<void> {
|
||||
const { dir, ref } = options;
|
||||
this.config.logger?.info(`Checking out branch {dir=${dir},ref=${ref}}`);
|
||||
|
||||
return git.checkout({ fs, dir, ref });
|
||||
}
|
||||
|
||||
async branch(options: { dir: string; ref: string }): Promise<void> {
|
||||
const { dir, ref } = options;
|
||||
this.config.logger?.info(`Creating branch {dir=${dir},ref=${ref}`);
|
||||
|
||||
return git.branch({ fs, dir, ref });
|
||||
}
|
||||
|
||||
async commit(options: {
|
||||
dir: string;
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
committer: { name: string; email: string };
|
||||
}): Promise<string> {
|
||||
const { dir, message, author, committer } = options;
|
||||
this.config.logger?.info(
|
||||
`Committing file to repo {dir=${dir},message=${message}}`,
|
||||
);
|
||||
return git.commit({ fs, dir, message, author, committer });
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/clone */
|
||||
async clone(options: {
|
||||
url: string;
|
||||
dir: string;
|
||||
ref?: string;
|
||||
depth?: number;
|
||||
noCheckout?: boolean;
|
||||
}): Promise<void> {
|
||||
const { url, dir, ref, depth, noCheckout } = options;
|
||||
this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
|
||||
|
||||
try {
|
||||
return await git.clone({
|
||||
fs,
|
||||
http,
|
||||
url,
|
||||
dir,
|
||||
ref,
|
||||
singleBranch: true,
|
||||
depth: depth ?? 1,
|
||||
noCheckout,
|
||||
onProgress: this.onProgressHandler(),
|
||||
headers: this.headers,
|
||||
onAuth: this.onAuth,
|
||||
});
|
||||
} catch (ex) {
|
||||
this.config.logger?.error(`Failed to clone repo {dir=${dir},url=${url}}`);
|
||||
if (ex.data) {
|
||||
throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/currentBranch */
|
||||
async currentBranch(options: {
|
||||
dir: string;
|
||||
fullName?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const { dir, fullName = false } = options;
|
||||
return git.currentBranch({ fs, dir, fullname: fullName }) as Promise<
|
||||
string | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/fetch */
|
||||
async fetch(options: {
|
||||
dir: string;
|
||||
remote?: string;
|
||||
tags?: boolean;
|
||||
}): Promise<void> {
|
||||
const { dir, remote = 'origin', tags = false } = options;
|
||||
this.config.logger?.info(
|
||||
`Fetching remote=${remote} for repository {dir=${dir}}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await git.fetch({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
remote,
|
||||
tags,
|
||||
onProgress: this.onProgressHandler(),
|
||||
headers: this.headers,
|
||||
onAuth: this.onAuth,
|
||||
});
|
||||
} catch (ex) {
|
||||
this.config.logger?.error(
|
||||
`Failed to fetch repo {dir=${dir},remote=${remote}}`,
|
||||
);
|
||||
if (ex.data) {
|
||||
throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
async init(options: { dir: string; defaultBranch?: string }): Promise<void> {
|
||||
const { dir, defaultBranch = 'master' } = options;
|
||||
this.config.logger?.info(`Init git repository {dir=${dir}}`);
|
||||
|
||||
return git.init({
|
||||
fs,
|
||||
dir,
|
||||
defaultBranch,
|
||||
});
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/merge */
|
||||
async merge(options: {
|
||||
dir: string;
|
||||
theirs: string;
|
||||
ours?: string;
|
||||
author: { name: string; email: string };
|
||||
committer: { name: string; email: string };
|
||||
}): Promise<MergeResult> {
|
||||
const { dir, theirs, ours, author, committer } = options;
|
||||
this.config.logger?.info(
|
||||
`Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`,
|
||||
);
|
||||
|
||||
// If ours is undefined, current branch is used.
|
||||
return git.merge({
|
||||
fs,
|
||||
dir,
|
||||
ours,
|
||||
theirs,
|
||||
author,
|
||||
committer,
|
||||
});
|
||||
}
|
||||
|
||||
async push(options: {
|
||||
dir: string;
|
||||
remote: string;
|
||||
remoteRef?: string;
|
||||
force?: boolean;
|
||||
}) {
|
||||
const { dir, remote, remoteRef, force } = options;
|
||||
this.config.logger?.info(
|
||||
`Pushing directory to remote {dir=${dir},remote=${remote}}`,
|
||||
);
|
||||
try {
|
||||
return await git.push({
|
||||
fs,
|
||||
dir,
|
||||
http,
|
||||
onProgress: this.onProgressHandler(),
|
||||
remoteRef,
|
||||
force,
|
||||
headers: this.headers,
|
||||
remote,
|
||||
onAuth: this.onAuth,
|
||||
});
|
||||
} catch (ex) {
|
||||
this.config.logger?.error(
|
||||
`Failed to push to repo {dir=${dir}, remote=${remote}}`,
|
||||
);
|
||||
if (ex.data) {
|
||||
throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/readCommit */
|
||||
async readCommit(options: {
|
||||
dir: string;
|
||||
sha: string;
|
||||
}): Promise<ReadCommitResult> {
|
||||
const { dir, sha } = options;
|
||||
return git.readCommit({ fs, dir, oid: sha });
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/remove */
|
||||
async remove(options: { dir: string; filepath: string }): Promise<void> {
|
||||
const { dir, filepath } = options;
|
||||
this.config.logger?.info(
|
||||
`Removing file from git index {dir=${dir},filepath=${filepath}}`,
|
||||
);
|
||||
return git.remove({ fs, dir, filepath });
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/resolveRef */
|
||||
async resolveRef(options: { dir: string; ref: string }): Promise<string> {
|
||||
const { dir, ref } = options;
|
||||
return git.resolveRef({ fs, dir, ref });
|
||||
}
|
||||
|
||||
/** https://isomorphic-git.org/docs/en/log */
|
||||
async log(options: {
|
||||
dir: string;
|
||||
ref?: string;
|
||||
}): Promise<ReadCommitResult[]> {
|
||||
const { dir, ref } = options;
|
||||
return git.log({
|
||||
fs,
|
||||
dir,
|
||||
ref: ref ?? 'HEAD',
|
||||
});
|
||||
}
|
||||
|
||||
private onAuth: AuthCallback;
|
||||
|
||||
private onProgressHandler = (): ProgressCallback => {
|
||||
let currentPhase = '';
|
||||
|
||||
return event => {
|
||||
if (currentPhase !== event.phase) {
|
||||
currentPhase = event.phase;
|
||||
this.config.logger?.info(event.phase);
|
||||
}
|
||||
const total = event.total
|
||||
? `${Math.round((event.loaded / event.total) * 100)}%`
|
||||
: event.loaded;
|
||||
this.config.logger?.debug(`status={${event.phase},total={${total}}}`);
|
||||
};
|
||||
};
|
||||
|
||||
static fromAuth = (options: StaticAuthOptions | AuthCallbackOptions) => {
|
||||
if (isAuthCallbackOptions(options)) {
|
||||
const { onAuth, logger } = options;
|
||||
return new Git({ onAuth, logger });
|
||||
}
|
||||
|
||||
const { username, password, token, logger } = options;
|
||||
return new Git({ onAuth: () => ({ username, password }), token, logger });
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Git } from './git';
|
||||
export type { StaticAuthOptions, AuthCallbackOptions } from './git';
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ServiceBuilderImpl } from './lib/ServiceBuilderImpl';
|
||||
import { ServiceBuilder } from './types';
|
||||
|
||||
/**
|
||||
* Creates a new service builder.
|
||||
* @public
|
||||
* @deprecated We are going to deprecated this old way of creating services in a near future, if you are using this service helper, please checkout the {@link https://backstage.io/docs/backend-system/building-backends/migrating | backend} and {@link https://backstage.io/docs/backend-system/building-plugins-and-modules/migrating | plugin} migration guides.
|
||||
*/
|
||||
export function createServiceBuilder(_module: NodeModule): ServiceBuilder {
|
||||
return new ServiceBuilderImpl(_module);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import * as winston from 'winston';
|
||||
import request from 'supertest';
|
||||
import { createStatusCheckRouter } from './createStatusCheckRouter';
|
||||
|
||||
describe('createStatusCheckRouter', () => {
|
||||
const logger = winston.createLogger();
|
||||
|
||||
it('gives status 200 when using default path', async () => {
|
||||
const app = express();
|
||||
app.use('', await createStatusCheckRouter({ logger }));
|
||||
|
||||
const response = await request(app).get('/healthcheck');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
it('gives status 200 when using custom path', async () => {
|
||||
const app = express();
|
||||
app.use('', await createStatusCheckRouter({ logger, path: '/ready' }));
|
||||
|
||||
const response = await request(app).get('/ready');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
it('gives status 500 when status check throws an error', async () => {
|
||||
const app = express();
|
||||
const statusCheck = () => {
|
||||
throw Error('error!');
|
||||
};
|
||||
app.use('', await createStatusCheckRouter({ logger, statusCheck }));
|
||||
|
||||
const response = await request(app).get('/healthcheck');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import Router from 'express-promise-router';
|
||||
import express from 'express';
|
||||
import { errorHandler, statusCheckHandler, StatusCheck } from '..';
|
||||
|
||||
/**
|
||||
* Creates a default status checking router, that you can add to your express
|
||||
* app.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This adds a `/healthcheck` route (or any other path, if given as an
|
||||
* argument), which your infra can call to see if the service is ready to serve
|
||||
* requests.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Migrate to the {@link https://backstage.io/docs/backend-system/ | new backend system} and use the {@link https://backstage.io/docs/backend-system/core-services/root-health | Root Health Service} instead.
|
||||
*/
|
||||
export async function createStatusCheckRouter(options: {
|
||||
logger: LoggerService;
|
||||
/**
|
||||
* The path (including a leading slash) that the health check should be
|
||||
* mounted on.
|
||||
*
|
||||
* @defaultValue '/healthcheck'
|
||||
*/
|
||||
path?: string;
|
||||
/**
|
||||
* If not implemented, the default express middleware always returns 200.
|
||||
* Override this to implement your own logic for a health check.
|
||||
*/
|
||||
statusCheck?: StatusCheck;
|
||||
}): Promise<express.Router> {
|
||||
const router = Router();
|
||||
const { path = '/healthcheck', statusCheck } = options;
|
||||
|
||||
router.use(path, await statusCheckHandler({ statusCheck }));
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createServiceBuilder } from './createServiceBuilder';
|
||||
export { createStatusCheckRouter } from './createStatusCheckRouter';
|
||||
export type { ServiceBuilder, RequestLoggingHandlerFactory } from './types';
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { applyCspDirectives, ServiceBuilderImpl } from './ServiceBuilderImpl';
|
||||
|
||||
describe('ServiceBuilderImpl', () => {
|
||||
describe('applyCspDirectives', () => {
|
||||
it('copies actual values', () => {
|
||||
const result = applyCspDirectives({ key: ['value'] });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
'default-src': ["'self'"],
|
||||
key: ['value'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes false value keys', () => {
|
||||
const result = applyCspDirectives({ 'upgrade-insecure-requests': false });
|
||||
expect(result!['upgrade-insecure-requests']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCustomErrorHandler', () => {
|
||||
it('check if custom error handler is undefined', () => {
|
||||
const serviceBuilder = new ServiceBuilderImpl(module);
|
||||
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
|
||||
expect(serviceBuilderProto.errorHandler).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds custom error handler', () => {
|
||||
const serviceBuilder = new ServiceBuilderImpl(module);
|
||||
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
|
||||
const customErrorHandler = (
|
||||
error: Error,
|
||||
_req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
next(error);
|
||||
};
|
||||
serviceBuilderProto.setErrorHandler(customErrorHandler);
|
||||
expect(serviceBuilderProto.errorHandler).toEqual(customErrorHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express, { Router, ErrorRequestHandler } from 'express';
|
||||
import helmet, { HelmetOptions } from 'helmet';
|
||||
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
|
||||
import * as http from 'http';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { useHotCleanup } from '../../hot';
|
||||
import { getRootLogger } from '../../logging';
|
||||
import {
|
||||
errorHandler as defaultErrorHandler,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler as defaultRequestLoggingHandler,
|
||||
} from '../../middleware';
|
||||
import { RequestLoggingHandlerFactory, ServiceBuilder } from '../types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
readCorsOptions,
|
||||
readHelmetOptions,
|
||||
readHttpServerOptions,
|
||||
HttpServerOptions,
|
||||
createHttpServer,
|
||||
} from '../../../../../backend-defaults/src/entrypoints/rootHttpRouter/http';
|
||||
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private logger: LoggerService | undefined;
|
||||
private serverOptions: HttpServerOptions;
|
||||
private helmetOptions: HelmetOptions;
|
||||
private corsOptions: cors.CorsOptions;
|
||||
private routers: [string, Router][];
|
||||
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
|
||||
private errorHandler: ErrorRequestHandler | undefined;
|
||||
private useDefaultErrorHandler: boolean;
|
||||
// Reference to the module where builder is created - needed for hot module
|
||||
// reloading
|
||||
private module: NodeModule;
|
||||
|
||||
constructor(moduleRef: NodeModule) {
|
||||
this.routers = [];
|
||||
this.module = moduleRef;
|
||||
this.useDefaultErrorHandler = true;
|
||||
|
||||
this.serverOptions = readHttpServerOptions();
|
||||
this.corsOptions = readCorsOptions();
|
||||
this.helmetOptions = readHelmetOptions();
|
||||
}
|
||||
|
||||
loadConfig(config: Config): ServiceBuilder {
|
||||
const backendConfig = config.getOptionalConfig('backend');
|
||||
|
||||
this.serverOptions = readHttpServerOptions(backendConfig);
|
||||
this.corsOptions = readCorsOptions(backendConfig);
|
||||
this.helmetOptions = readHelmetOptions(backendConfig);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
setPort(port: number): ServiceBuilder {
|
||||
this.serverOptions.listen.port = port;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHost(host: string): ServiceBuilder {
|
||||
this.serverOptions.listen.host = host;
|
||||
return this;
|
||||
}
|
||||
|
||||
setLogger(logger: LoggerService): ServiceBuilder {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHttpsSettings(settings: {
|
||||
certificate: { key: string; cert: string } | { hostname: string };
|
||||
}): ServiceBuilder {
|
||||
if ('hostname' in settings.certificate) {
|
||||
this.serverOptions.https = {
|
||||
certificate: {
|
||||
...settings.certificate,
|
||||
type: 'generated',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.serverOptions.https = {
|
||||
certificate: {
|
||||
...settings.certificate,
|
||||
type: 'pem',
|
||||
},
|
||||
};
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
enableCors(options: cors.CorsOptions): ServiceBuilder {
|
||||
this.corsOptions = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
setCsp(options: CspOptions): ServiceBuilder {
|
||||
const csp = this.helmetOptions.contentSecurityPolicy;
|
||||
this.helmetOptions.contentSecurityPolicy = {
|
||||
...(typeof csp === 'object' ? csp : {}),
|
||||
directives: applyCspDirectives(options),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
addRouter(root: string, router: Router): ServiceBuilder {
|
||||
this.routers.push([root, router]);
|
||||
return this;
|
||||
}
|
||||
|
||||
setRequestLoggingHandler(
|
||||
requestLoggingHandler: RequestLoggingHandlerFactory,
|
||||
) {
|
||||
this.requestLoggingHandler = requestLoggingHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler) {
|
||||
this.errorHandler = errorHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
disableDefaultErrorHandler() {
|
||||
this.useDefaultErrorHandler = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
async start(): Promise<http.Server> {
|
||||
const app = express();
|
||||
const logger = this.logger ?? getRootLogger();
|
||||
|
||||
app.use(helmet(this.helmetOptions));
|
||||
app.use(cors(this.corsOptions));
|
||||
app.use(compression());
|
||||
app.use(
|
||||
(this.requestLoggingHandler ?? defaultRequestLoggingHandler)(logger),
|
||||
);
|
||||
for (const [root, route] of this.routers) {
|
||||
app.use(root, route);
|
||||
}
|
||||
app.use(notFoundHandler());
|
||||
|
||||
if (this.errorHandler) {
|
||||
app.use(this.errorHandler);
|
||||
}
|
||||
|
||||
if (this.useDefaultErrorHandler) {
|
||||
app.use(defaultErrorHandler());
|
||||
}
|
||||
|
||||
const server = await createHttpServer(app, this.serverOptions, { logger });
|
||||
|
||||
useHotCleanup(this.module, () =>
|
||||
server.stop().catch(error => {
|
||||
console.error(error);
|
||||
}),
|
||||
);
|
||||
|
||||
await server.start();
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Rugvip): This is a duplicate of the same logic over in backend-app-api.
|
||||
// It's needed as we don't want to export this helper from there, but need
|
||||
// It to implement the setCsp method here.
|
||||
export function applyCspDirectives(
|
||||
directives: Record<string, string[] | false> | undefined,
|
||||
): ContentSecurityPolicyOptions['directives'] {
|
||||
const result: ContentSecurityPolicyOptions['directives'] =
|
||||
helmet.contentSecurityPolicy.getDefaultDirectives();
|
||||
|
||||
// TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval.
|
||||
// It should be replaced by any other solution that doesn't require unsafe-eval.
|
||||
result['script-src'] = ["'self'", "'unsafe-eval'"];
|
||||
|
||||
// TODO(Rugvip): This is removed so that we maintained backwards compatibility
|
||||
// when bumping to helmet v5, we could remove this as well as
|
||||
// skip setting `useDefaults: false` in the future.
|
||||
delete result['form-action'];
|
||||
|
||||
if (directives) {
|
||||
for (const [key, value] of Object.entries(directives)) {
|
||||
if (value === false) {
|
||||
delete result[key];
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import cors from 'cors';
|
||||
import { Router, RequestHandler, ErrorRequestHandler } from 'express';
|
||||
import { Server } from 'http';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* A helper for building backend service instances.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is being deprecated along with the {@link @backstage/backend-common#createServiceBuilder} function.
|
||||
*/
|
||||
export type ServiceBuilder = {
|
||||
/**
|
||||
* Sets the service parameters based on configuration.
|
||||
*
|
||||
* @param config - The configuration to read
|
||||
*/
|
||||
loadConfig(config: Config): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Sets the port to listen on.
|
||||
*
|
||||
* If no port is specified, the service will first look for an environment
|
||||
* variable named PORT and use that if present, otherwise it picks a default
|
||||
* port (7007).
|
||||
*
|
||||
* @param port - The port to listen on
|
||||
*/
|
||||
setPort(port: number): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Sets the host to listen on.
|
||||
*
|
||||
* '' is express default, which listens to all interfaces.
|
||||
*
|
||||
* @param host - The host to listen on
|
||||
*/
|
||||
setHost(host: string): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Sets the logger to use for service-specific logging.
|
||||
*
|
||||
* If no logger is given, the default root logger is used.
|
||||
*
|
||||
* @param logger - A winston logger
|
||||
*/
|
||||
setLogger(logger: LoggerService): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Enables CORS handling using the given settings.
|
||||
*
|
||||
* If this method is not called, the resulting service will not have any
|
||||
* built in CORS handling.
|
||||
*
|
||||
* @param options - Standard CORS options
|
||||
*/
|
||||
enableCors(options: cors.CorsOptions): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Configure self-signed certificate generation options.
|
||||
*
|
||||
* If this method is not called, the resulting service will use sensible defaults
|
||||
*
|
||||
* @param options - Standard certificate options
|
||||
*/
|
||||
setHttpsSettings(settings: {
|
||||
certificate: { key: string; cert: string } | { hostname: string };
|
||||
}): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Adds a router (similar to the express .use call) to the service.
|
||||
*
|
||||
* @param root - The root URL to bind to (e.g. "/api/function1")
|
||||
* @param router - An express router
|
||||
*/
|
||||
addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Set the request logging handler
|
||||
*
|
||||
* If no handler is given the default one is used
|
||||
*
|
||||
* @param requestLoggingHandler - a factory function that given a logger returns an handler
|
||||
*/
|
||||
setRequestLoggingHandler(
|
||||
requestLoggingHandler: RequestLoggingHandlerFactory,
|
||||
): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Sets an additional errorHandler to run before the defaultErrorHandler.
|
||||
*
|
||||
* For execution of only the custom error handler make sure to also invoke disableDefaultErrorHandler()
|
||||
* otherwise the defaultErrorHandler is executed at the end of the error middleware chain.
|
||||
*
|
||||
* @param errorHandler - an error handler
|
||||
*/
|
||||
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Disables the default error handler
|
||||
*/
|
||||
disableDefaultErrorHandler(): ServiceBuilder;
|
||||
|
||||
/**
|
||||
* Starts the server using the given settings.
|
||||
*/
|
||||
start(): Promise<Server>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory for request loggers.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is being deprecated along with the {@link @backstage/backend-common#createServiceBuilder} function.
|
||||
*/
|
||||
export type RequestLoggingHandlerFactory = (
|
||||
logger?: LoggerService,
|
||||
) => RequestHandler;
|
||||
@@ -1,392 +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 { ConfigReader } from '@backstage/config';
|
||||
import * as jose from 'jose';
|
||||
import { ServerTokenManager } from './ServerTokenManager';
|
||||
import { TokenManager } from './types';
|
||||
import { DateTime } from 'luxon';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
const emptyConfig = new ConfigReader({});
|
||||
const configWithSecret = new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a-secret-key' }] } },
|
||||
});
|
||||
const env = process.env;
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
describe('ServerTokenManager', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return a token', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
expect((await tokenManager.getToken()).token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return a token string if using a noop TokenManager', async () => {
|
||||
const tokenManager = ServerTokenManager.noop();
|
||||
expect((await tokenManager.getToken()).token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('should not throw if token is valid', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
const { token } = await tokenManager.getToken();
|
||||
await expect(tokenManager.authenticate(token)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw if token is invalid', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
await expect(tokenManager.authenticate('random-string')).rejects.toThrow(
|
||||
/invalid server token/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate server tokens created by a different instance using the same secret', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token } = await tokenManager1.getToken();
|
||||
|
||||
await expect(tokenManager2.authenticate(token)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate server tokens created using any of the secrets', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
const tokenManager3 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] },
|
||||
},
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
const { token: token1 } = await tokenManager1.getToken();
|
||||
await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow();
|
||||
|
||||
const { token: token2 } = await tokenManager2.getToken();
|
||||
await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for server tokens created using a different secret', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
const { token } = await tokenManager1.getToken();
|
||||
|
||||
await expect(tokenManager2.authenticate(token)).rejects.toThrow(
|
||||
/invalid server token/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for server tokens created using a noop TokenManager', async () => {
|
||||
const noopTokenManager = ServerTokenManager.noop();
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
const { token } = await noopTokenManager.getToken();
|
||||
|
||||
await expect(tokenManager.authenticate(token)).rejects.toThrow(
|
||||
/invalid server token/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for server tokens created by a different generated secret', async () => {
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(emptyConfig, {
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token } = await tokenManager2.getToken();
|
||||
|
||||
await expect(tokenManager1.authenticate(token)).rejects.toThrow(
|
||||
/invalid server token/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for expired tokens, and re-issue new ones', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token: token1 } = await tokenManager.getToken();
|
||||
await expect(tokenManager.authenticate(token1)).resolves.not.toThrow();
|
||||
|
||||
// Right before the reissue timeout, it still returns the same token
|
||||
jest.advanceTimersByTime(9 * 60 * 1000);
|
||||
const { token: token1Again } = await tokenManager.getToken();
|
||||
expect(token1).toEqual(token1Again);
|
||||
await expect(tokenManager.authenticate(token1)).resolves.not.toThrow();
|
||||
|
||||
// Right after the reissue timeout, the old ones are still valid but returning a new token
|
||||
jest.advanceTimersByTime(2 * 60 * 1000);
|
||||
const { token: token2 } = await tokenManager.getToken();
|
||||
expect(token1).not.toEqual(token2);
|
||||
await expect(tokenManager.authenticate(token1)).resolves.not.toThrow();
|
||||
await expect(tokenManager.authenticate(token2)).resolves.not.toThrow();
|
||||
|
||||
// After expiry of the first one, it gets warnings but the newest one is still valid
|
||||
jest.advanceTimersByTime(52 * 60 * 1000);
|
||||
await expect(tokenManager.authenticate(token1)).rejects.toThrow(
|
||||
'Invalid server token; caused by JWTExpired: "exp" claim timestamp check failed',
|
||||
);
|
||||
await expect(tokenManager.authenticate(token2)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with a manually crafted JWT', async () => {
|
||||
const secret = 'a1b2c3';
|
||||
const token = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('backstage-server')
|
||||
.setExpirationTime(Date.now() + 1000 * 60 * 60)
|
||||
.sign(jose.base64url.decode(secret));
|
||||
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
await expect(tokenManager.authenticate(token)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject tokens without exp claim', async () => {
|
||||
const secret = 'a1b2c3';
|
||||
const token = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('backstage-server')
|
||||
.sign(jose.base64url.decode(secret));
|
||||
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret }] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
await expect(tokenManager.authenticate(token)).rejects.toThrow(
|
||||
'Invalid server token; caused by AuthenticationError: Server-to-server token had no exp claim',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads both old and new config', async () => {
|
||||
const oldSecret = jose.base64url.encode('old');
|
||||
const newSecret = jose.base64url.encode('new');
|
||||
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [{ secret: oldSecret }],
|
||||
externalAccess: [
|
||||
{ type: 'legacy', options: { secret: newSecret } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
const oldToken = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('backstage-server')
|
||||
.setExpirationTime(DateTime.now().plus({ minutes: 1 }).toUnixInteger())
|
||||
.sign(jose.base64url.decode(oldSecret));
|
||||
const newToken = await new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject('backstage-server')
|
||||
.setExpirationTime(DateTime.now().plus({ minutes: 1 }).toUnixInteger())
|
||||
.sign(jose.base64url.decode(newSecret));
|
||||
|
||||
await expect(tokenManager.authenticate(oldToken)).resolves.not.toThrow();
|
||||
await expect(tokenManager.authenticate(newToken)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromConfig', () => {
|
||||
describe('NODE_ENV === production', () => {
|
||||
it('should throw if backend auth configuration is missing', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(emptyConfig, { logger }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if no keys are included in the configuration', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [] } },
|
||||
}),
|
||||
{ logger },
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if any key is missing a secret property', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [{ secret: '123' }, {}, { secret: '789' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ logger },
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw errors when disabled', async () => {
|
||||
const manager = ServerTokenManager.fromConfig(new ConfigReader({}), {
|
||||
logger,
|
||||
allowDisabledTokenManager: true,
|
||||
});
|
||||
|
||||
await expect(manager.getToken()).rejects.toThrow(
|
||||
'Unable to generate legacy token',
|
||||
);
|
||||
await expect(manager.authenticate('nah')).rejects.toThrow(
|
||||
'Unable to authenticate legacy token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NODE_ENV === development', () => {
|
||||
const generateSecretSpy = jest.spyOn(jose, 'generateSecret');
|
||||
|
||||
beforeEach(() => {
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should generate a key if no config is provided', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(emptyConfig, {
|
||||
logger,
|
||||
});
|
||||
const token = await tokenManager.getToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(generateSecretSpy).toHaveBeenCalledWith('HS256');
|
||||
});
|
||||
|
||||
it('should generate a key if no keys are provided in the configuration', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: { auth: { keys: [] } },
|
||||
}),
|
||||
{ logger },
|
||||
);
|
||||
const token = await tokenManager.getToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(generateSecretSpy).toHaveBeenCalledWith('HS256');
|
||||
});
|
||||
|
||||
it('should use provided secrets if config is provided', () => {
|
||||
ServerTokenManager.fromConfig(configWithSecret, { logger });
|
||||
expect(generateSecretSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('noop', () => {
|
||||
let noopTokenManager: TokenManager;
|
||||
|
||||
beforeEach(() => {
|
||||
noopTokenManager = ServerTokenManager.noop();
|
||||
});
|
||||
|
||||
it('should accept tokens it generates', async () => {
|
||||
const { token } = await noopTokenManager.getToken();
|
||||
|
||||
await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept tokens generated by other noop token managers', async () => {
|
||||
const noopTokenManager2 = ServerTokenManager.noop();
|
||||
await expect(
|
||||
noopTokenManager.authenticate(
|
||||
(
|
||||
await noopTokenManager2.getToken()
|
||||
).token,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept signed tokens', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret, {
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
noopTokenManager.authenticate((await tokenManager.getToken()).token),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,249 +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 { Config } from '@backstage/config';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { base64url, exportJWK, generateSecret, jwtVerify, SignJWT } from 'jose';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { TokenManager } from './types';
|
||||
|
||||
const TOKEN_ALG = 'HS256';
|
||||
const TOKEN_SUB = 'backstage-server';
|
||||
const TOKEN_EXPIRY_AFTER = Duration.fromObject({ hours: 1 });
|
||||
const TOKEN_REISSUE_AFTER = Duration.fromObject({ minutes: 10 });
|
||||
|
||||
/**
|
||||
* A token manager that issues static fake tokens and never fails
|
||||
* authentication. This can be useful for testing.
|
||||
*/
|
||||
class NoopTokenManager implements TokenManager {
|
||||
public readonly isInsecureServerTokenManager: boolean = true;
|
||||
|
||||
async getToken() {
|
||||
return { token: '' };
|
||||
}
|
||||
|
||||
async authenticate() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A token manager that throws an error when trying to generate or authenticate tokens.
|
||||
*/
|
||||
class DisabledTokenManager implements TokenManager {
|
||||
async getToken(): Promise<{ token: string }> {
|
||||
throw new Error(
|
||||
"Unable to generate legacy token, no legacy keys are configured in 'backend.auth.keys' or 'backend.auth.externalAccess'",
|
||||
);
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
throw new AuthenticationError(
|
||||
"Unable to authenticate legacy token, no legacy keys are configured in 'backend.auth.keys' or 'backend.auth.externalAccess'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link ServerTokenManager}.
|
||||
*
|
||||
* @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 ServerTokenManagerOptions {
|
||||
/**
|
||||
* The logger to use.
|
||||
*/
|
||||
logger: LoggerService;
|
||||
|
||||
/**
|
||||
* Whether to disable the token manager if no keys are configured.
|
||||
*/
|
||||
allowDisabledTokenManager?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and validates tokens for use during service-to-service
|
||||
* authentication.
|
||||
*
|
||||
* @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 class ServerTokenManager implements TokenManager {
|
||||
private readonly options: ServerTokenManagerOptions;
|
||||
private readonly verificationKeys: Uint8Array[];
|
||||
private signingKey: Uint8Array;
|
||||
private privateKeyPromise: Promise<void> | undefined;
|
||||
private currentTokenPromise: Promise<{ token: string }> | undefined;
|
||||
|
||||
/**
|
||||
* Creates a token manager that issues static fake tokens and never fails
|
||||
* authentication. This can be useful for testing.
|
||||
*/
|
||||
static noop(): TokenManager {
|
||||
return new NoopTokenManager();
|
||||
}
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: ServerTokenManagerOptions,
|
||||
): TokenManager {
|
||||
const oldSecrets = config
|
||||
.getOptionalConfigArray('backend.auth.keys')
|
||||
?.map(c => c.getString('secret'));
|
||||
const newSecrets = config
|
||||
.getOptionalConfigArray('backend.auth.externalAccess')
|
||||
?.filter(c => c.getString('type') === 'legacy')
|
||||
.map(c => c.getString('options.secret'));
|
||||
const secrets = [...(oldSecrets ?? []), ...(newSecrets ?? [])];
|
||||
|
||||
if (secrets.length) {
|
||||
return new ServerTokenManager(secrets, options);
|
||||
}
|
||||
|
||||
// When using the new backend system with new auth services we instead rely
|
||||
// on the new plugin auth and external access configurations. If no legacy
|
||||
// keys are configured we disable the token manager completely, rather than
|
||||
// requiring users to configure legacy keys.
|
||||
if (options.allowDisabledTokenManager) {
|
||||
return new DisabledTokenManager();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'You must configure at least one key in backend.auth.keys for production.',
|
||||
);
|
||||
}
|
||||
|
||||
// For development, if a secret has not been configured, we auto generate a secret instead of throwing.
|
||||
options.logger.warn(
|
||||
'Generated a secret for service-to-service authentication: DEVELOPMENT USE ONLY.',
|
||||
);
|
||||
return new ServerTokenManager([], options);
|
||||
}
|
||||
|
||||
private constructor(secrets: string[], options: ServerTokenManagerOptions) {
|
||||
if (!secrets.length && process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'No secrets provided when constructing ServerTokenManager',
|
||||
);
|
||||
}
|
||||
this.options = options;
|
||||
this.verificationKeys = secrets.map(s => base64url.decode(s));
|
||||
this.signingKey = this.verificationKeys[0];
|
||||
}
|
||||
|
||||
// Called when no keys have been generated yet in the dev environment
|
||||
private async generateKeys(): Promise<void> {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'Key generation is not supported outside of the dev environment',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.privateKeyPromise) {
|
||||
return this.privateKeyPromise;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const secret = await generateSecret(TOKEN_ALG);
|
||||
const jwk = await exportJWK(secret);
|
||||
this.verificationKeys.push(base64url.decode(jwk.k ?? ''));
|
||||
this.signingKey = this.verificationKeys[0];
|
||||
return;
|
||||
})();
|
||||
|
||||
try {
|
||||
this.privateKeyPromise = promise;
|
||||
await promise;
|
||||
} catch (error) {
|
||||
// If we fail to generate a new key, we need to clear the state so that
|
||||
// the next caller will try to generate another key.
|
||||
this.options.logger.error(`Failed to generate new key, ${error}`);
|
||||
delete this.privateKeyPromise;
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async getToken(): Promise<{ token: string }> {
|
||||
if (!this.verificationKeys.length) {
|
||||
await this.generateKeys();
|
||||
}
|
||||
|
||||
if (this.currentTokenPromise) {
|
||||
return this.currentTokenPromise;
|
||||
}
|
||||
|
||||
const result = Promise.resolve().then(async () => {
|
||||
const jwt = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: TOKEN_ALG })
|
||||
.setSubject(TOKEN_SUB)
|
||||
.setExpirationTime(
|
||||
DateTime.now().plus(TOKEN_EXPIRY_AFTER).toUnixInteger(),
|
||||
)
|
||||
.sign(this.signingKey);
|
||||
return { token: jwt };
|
||||
});
|
||||
|
||||
this.currentTokenPromise = result;
|
||||
|
||||
result
|
||||
.then(() => {
|
||||
setTimeout(() => {
|
||||
this.currentTokenPromise = undefined;
|
||||
}, TOKEN_REISSUE_AFTER.toMillis());
|
||||
})
|
||||
.catch(() => {
|
||||
this.currentTokenPromise = undefined;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<void> {
|
||||
let verifyError = undefined;
|
||||
|
||||
for (const key of this.verificationKeys) {
|
||||
try {
|
||||
const {
|
||||
protectedHeader: { alg },
|
||||
payload: { sub, exp },
|
||||
} = await jwtVerify(token, key);
|
||||
|
||||
if (alg !== TOKEN_ALG) {
|
||||
throw new AuthenticationError(`Illegal alg "${alg}"`);
|
||||
}
|
||||
|
||||
if (sub !== TOKEN_SUB) {
|
||||
throw new AuthenticationError(`Illegal sub "${sub}"`);
|
||||
}
|
||||
|
||||
if (typeof exp !== 'number') {
|
||||
throw new AuthenticationError(
|
||||
'Server-to-server token had no exp claim',
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
// Catch the verify exception and continue
|
||||
verifyError = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Invalid server token', verifyError);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +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.
|
||||
*/
|
||||
|
||||
export { ServerTokenManager } from './ServerTokenManager';
|
||||
export type { ServerTokenManagerOptions } from './ServerTokenManager';
|
||||
export type { TokenManager } from './types';
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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 TokenManager {
|
||||
/**
|
||||
* Fetches a valid token.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Tokens are valid for roughly one hour; the actual deadline is set in the
|
||||
* payload `exp` claim. Never hold on to tokens for reuse; always ask for a
|
||||
* new one for each outgoing request. This ensures that you always get a
|
||||
* valid, fresh one.
|
||||
*/
|
||||
getToken(): Promise<{ token: string }>;
|
||||
|
||||
/**
|
||||
* Validates a given token.
|
||||
*/
|
||||
authenticate(token: string): Promise<void>;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Writable } from 'stream';
|
||||
|
||||
/**
|
||||
* Allows defining access credentials for a registry
|
||||
* Follows dockerode auth configuration:
|
||||
* {@link https://github.com/apocas/dockerode?tab=readme-ov-file#pull-from-private-repos}
|
||||
*
|
||||
* @public
|
||||
* @deprecated This interface is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export interface PullOptions {
|
||||
authconfig?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
email?: string;
|
||||
serveraddress?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to the {@link ContainerRunner.runContainer} method.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export type RunContainerOptions = {
|
||||
imageName: string;
|
||||
command?: string | string[];
|
||||
args: string[];
|
||||
logStream?: Writable;
|
||||
mountDirs?: Record<string, string>;
|
||||
workingDir?: string;
|
||||
envVars?: Record<string, string>;
|
||||
pullImage?: boolean;
|
||||
defaultUser?: boolean;
|
||||
pullOptions?: PullOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the running of containers, on behalf of others.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This interface is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export interface ContainerRunner {
|
||||
/**
|
||||
* Runs a container image to completion.
|
||||
*/
|
||||
runContainer(opts: RunContainerOptions): Promise<void>;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import Docker from 'dockerode';
|
||||
import Stream, { PassThrough } from 'stream';
|
||||
import { ContainerRunner } from './ContainerRunner';
|
||||
import { DockerContainerRunner, UserOptions } from './DockerContainerRunner';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const mockDocker = new Docker() as jest.Mocked<Docker>;
|
||||
|
||||
describe('DockerContainerRunner', () => {
|
||||
let containerTaskApi: ContainerRunner;
|
||||
|
||||
const inputDir = createMockDirectory();
|
||||
const outputDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
inputDir.clear();
|
||||
outputDir.clear();
|
||||
|
||||
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
|
||||
_image: string,
|
||||
_something: any,
|
||||
handler: (err: Error | undefined, stream: PassThrough) => void,
|
||||
) => {
|
||||
const mockStream = new PassThrough();
|
||||
handler(undefined, mockStream);
|
||||
mockStream.end();
|
||||
}) as any);
|
||||
|
||||
jest
|
||||
.spyOn(mockDocker, 'run')
|
||||
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
|
||||
|
||||
jest
|
||||
.spyOn(mockDocker, 'ping')
|
||||
.mockResolvedValue(Buffer.from('OK', 'utf-8'));
|
||||
|
||||
containerTaskApi = new DockerContainerRunner({ dockerClient: mockDocker });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const imageName = 'dockerOrg/image';
|
||||
const args = ['bash', '-c', 'echo test'];
|
||||
const mountDirs = {
|
||||
[inputDir.path]: '/input',
|
||||
[outputDir.path]: '/output',
|
||||
};
|
||||
const workingDir = inputDir.path;
|
||||
const envVars = { HOME: '/tmp', LOG_LEVEL: 'debug' };
|
||||
const envVarsArray = ['HOME=/tmp', 'LOG_LEVEL=debug'];
|
||||
|
||||
it('should pull the docker container', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
});
|
||||
|
||||
expect(mockDocker.pull).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pull the docker container with authentication', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
pullOptions: {
|
||||
authconfig: {
|
||||
auth: 'aaaaaaaaa',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockDocker.pull).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
{
|
||||
authconfig: {
|
||||
auth: 'aaaaaaaaa',
|
||||
},
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not pull the docker container when pullImage is false', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
pullImage: false,
|
||||
});
|
||||
|
||||
expect(mockDocker.pull).not.toHaveBeenCalled();
|
||||
expect(mockDocker.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the dockerClient run command with the correct arguments passed through', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
mountDirs,
|
||||
envVars,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
args,
|
||||
expect.any(Stream),
|
||||
expect.objectContaining({
|
||||
Env: envVarsArray,
|
||||
WorkingDir: workingDir,
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: expect.arrayContaining([
|
||||
`${await fs.realpath(inputDir.path)}:/input`,
|
||||
`${await fs.realpath(outputDir.path)}:/output`,
|
||||
]),
|
||||
},
|
||||
Volumes: {
|
||||
'/input': {},
|
||||
'/output': {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ping docker to test availability', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
});
|
||||
|
||||
expect(mockDocker.ping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through the user and group id from the host machine and set the home dir', async () => {
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
});
|
||||
|
||||
const userOptions: UserOptions = {};
|
||||
if (process.getuid && process.getgid) {
|
||||
userOptions.User = `${process.getuid()}:${process.getgid()}`;
|
||||
}
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
args,
|
||||
expect.any(Stream),
|
||||
expect.objectContaining({
|
||||
...userOptions,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a correct error if the command fails in docker', async () => {
|
||||
mockDocker.run.mockResolvedValueOnce([
|
||||
{
|
||||
Error: new Error('Something went wrong with docker'),
|
||||
StatusCode: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
}),
|
||||
).rejects.toThrow(/Something went wrong with docker/);
|
||||
});
|
||||
|
||||
describe('where docker is unavailable', () => {
|
||||
const dockerError = 'a docker error';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => {
|
||||
throw new Error(dockerError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw with a descriptive error message including the docker error message', async () => {
|
||||
await expect(
|
||||
containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
}),
|
||||
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through the log stream to the docker client', async () => {
|
||||
const logStream = new PassThrough();
|
||||
await containerTaskApi.runContainer({
|
||||
imageName,
|
||||
args,
|
||||
logStream,
|
||||
});
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
args,
|
||||
logStream,
|
||||
expect.objectContaining({
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: [],
|
||||
},
|
||||
Volumes: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Docker from 'dockerode';
|
||||
import fs from 'fs-extra';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { PassThrough } from 'stream';
|
||||
import { ContainerRunner, RunContainerOptions } from './ContainerRunner';
|
||||
|
||||
export type UserOptions = {
|
||||
User?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A {@link ContainerRunner} for Docker containers.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This class is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export class DockerContainerRunner implements ContainerRunner {
|
||||
private readonly dockerClient: Docker;
|
||||
|
||||
constructor(options: { dockerClient: Docker }) {
|
||||
this.dockerClient = options.dockerClient;
|
||||
}
|
||||
|
||||
async runContainer(options: RunContainerOptions) {
|
||||
const {
|
||||
imageName,
|
||||
command,
|
||||
args,
|
||||
logStream = new PassThrough(),
|
||||
mountDirs = {},
|
||||
workingDir,
|
||||
envVars = {},
|
||||
pullImage = true,
|
||||
defaultUser = false,
|
||||
pullOptions = {},
|
||||
} = options;
|
||||
|
||||
// Show a better error message when Docker is unavailable.
|
||||
try {
|
||||
await this.dockerClient.ping();
|
||||
} catch (e) {
|
||||
throw new ForwardedError(
|
||||
'This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with',
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
if (pullImage) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.dockerClient.pull(imageName, pullOptions, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (!stream) {
|
||||
reject(
|
||||
new Error(
|
||||
'Unexpeected error: no stream returned from Docker while pulling image',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
stream.pipe(logStream, { end: false });
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (error: Error) => reject(error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const userOptions: UserOptions = {};
|
||||
if (!defaultUser && process.getuid && process.getgid) {
|
||||
// Files that are created inside the Docker container will be owned by
|
||||
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
|
||||
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
|
||||
// So we set the user in the container as the same user and group id as the host.
|
||||
// On Windows we don't have process.getuid nor process.getgid
|
||||
userOptions.User = `${process.getuid()}:${process.getgid()}`;
|
||||
}
|
||||
|
||||
// Initialize volumes to mount based on mountDirs map
|
||||
const Volumes: { [T: string]: object } = {};
|
||||
for (const containerDir of Object.values(mountDirs)) {
|
||||
Volumes[containerDir] = {};
|
||||
}
|
||||
|
||||
// Create bind volumes
|
||||
const Binds: string[] = [];
|
||||
for (const [hostDir, containerDir] of Object.entries(mountDirs)) {
|
||||
// Need to use realpath here as Docker mounting does not like
|
||||
// symlinks for binding volumes
|
||||
const realHostDir = await fs.realpath(hostDir);
|
||||
Binds.push(`${realHostDir}:${containerDir}`);
|
||||
}
|
||||
|
||||
// Create docker environment variables array
|
||||
const Env = new Array<string>();
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
Env.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
const [{ Error: error, StatusCode: statusCode }] =
|
||||
await this.dockerClient.run(imageName, args, logStream, {
|
||||
Volumes,
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds,
|
||||
},
|
||||
...(workingDir ? { WorkingDir: workingDir } : {}),
|
||||
Entrypoint: command,
|
||||
Env,
|
||||
...userOptions,
|
||||
} as Docker.ContainerCreateOptions);
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Docker failed to run with the following error message: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode !== 0) {
|
||||
throw new Error(
|
||||
`Docker container returned a non-zero exit code (${statusCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CoreV1Api,
|
||||
KubeConfig,
|
||||
RbacAuthorizationV1Api,
|
||||
} from '@kubernetes/client-node';
|
||||
import {
|
||||
KubernetesContainerRunner,
|
||||
KubernetesContainerRunnerOptions,
|
||||
} from './KubernetesContainerRunner';
|
||||
import { RunContainerOptions } from './ContainerRunner';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
// This ensures E2E tests that require a Kubernetes cluster are only run
|
||||
// where KUBERNETES_TESTS environment variable is true
|
||||
const describeIfKubernetes = Boolean(process.env.KUBERNETES_TESTS)
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
jest.setTimeout(10 * 1000);
|
||||
|
||||
describeIfKubernetes('KubernetesContainerRunner', () => {
|
||||
const kubeConfig = new KubeConfig();
|
||||
kubeConfig.loadFromDefault();
|
||||
const name = 'kube-runner';
|
||||
|
||||
it('should throw error when no namespace is configured', () => {
|
||||
const testConfig = new KubeConfig();
|
||||
testConfig.loadFromDefault();
|
||||
testConfig.addContext({
|
||||
name: 'test',
|
||||
cluster: kubeConfig.getCurrentCluster()!.name,
|
||||
user: kubeConfig.getCurrentUser()!.name,
|
||||
});
|
||||
testConfig.setCurrentContext('test');
|
||||
const test = () =>
|
||||
new KubernetesContainerRunner({
|
||||
kubeConfig: testConfig,
|
||||
name,
|
||||
});
|
||||
expect(test).toThrow(
|
||||
/Cannot read current namespace from Kubernetes cluster/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when mountBase is provided and podTemplate is invalid', () => {
|
||||
const error = /A Pod template containing the volume .+ is required/;
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
mountBase: {
|
||||
basePath: '/workdir',
|
||||
volumeName: 'workdir',
|
||||
},
|
||||
};
|
||||
const test = () => {
|
||||
return new KubernetesContainerRunner(options);
|
||||
};
|
||||
expect(test).toThrow(error);
|
||||
options.podTemplate = {};
|
||||
expect(test).toThrow(error);
|
||||
options.podTemplate.spec = { containers: [] };
|
||||
expect(test).toThrow(error);
|
||||
options.podTemplate.spec.volumes = [];
|
||||
expect(test).toThrow(error);
|
||||
});
|
||||
|
||||
it('should not run the container when the mounts are not subdirectories of the basePath', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
mountBase: {
|
||||
basePath: '/workdir',
|
||||
volumeName: 'workdir',
|
||||
},
|
||||
podTemplate: {
|
||||
spec: {
|
||||
containers: [],
|
||||
volumes: [
|
||||
{
|
||||
name: 'workdir',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const logStream = new PassThrough();
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'golang:1.17',
|
||||
args: ['echo', 'hello world'],
|
||||
logStream,
|
||||
mountDirs: {
|
||||
'/notWorkdir/app': '/app',
|
||||
},
|
||||
};
|
||||
await expect(containerRunner.runContainer(runOptions)).rejects.toThrow(
|
||||
`Mounted '/notWorkdir/app' dir should be subdirectories of '${
|
||||
options!.mountBase!.basePath
|
||||
}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed when the container command returns a 0 exit code', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const logStream = new PassThrough();
|
||||
const chunks: any[] = [];
|
||||
logStream.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'alpine',
|
||||
args: ['echo', 'hello world'],
|
||||
logStream,
|
||||
};
|
||||
await containerRunner.runContainer(runOptions);
|
||||
const result = Buffer.concat(chunks).toString('utf8');
|
||||
expect(result).toBe('hello world\n');
|
||||
});
|
||||
|
||||
it('should fail when container run time exceeds the timeout', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'alpine',
|
||||
args: ['sleep', '10'],
|
||||
};
|
||||
await expect(containerRunner.runContainer(runOptions)).rejects.toThrow(
|
||||
`Failed to complete in ${options.timeoutMs} ms`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when container command returns a non 0 exit code', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'alpine',
|
||||
args: ['fake'],
|
||||
};
|
||||
await expect(containerRunner.runContainer(runOptions)).rejects.toThrow(
|
||||
`Container execution failed`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when job creation fails', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'fake',
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'golang:1.17',
|
||||
args: ['echo', 'hello world'],
|
||||
};
|
||||
await expect(containerRunner.runContainer(runOptions)).rejects.toThrow(
|
||||
'Kubernetes Job creation failed with the following error message: namespaces "fake" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not close the original log stream', async () => {
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig,
|
||||
name,
|
||||
namespace: 'default',
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const logStream = new PassThrough();
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'alpine',
|
||||
args: ['echo', 'hello world'],
|
||||
logStream,
|
||||
};
|
||||
|
||||
await containerRunner.runContainer(runOptions);
|
||||
|
||||
expect(logStream.writableEnded).toBe(false);
|
||||
expect(logStream.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
describe('with namespace test', () => {
|
||||
let api: CoreV1Api;
|
||||
let authApi: RbacAuthorizationV1Api;
|
||||
|
||||
beforeAll(async () => {
|
||||
api = kubeConfig.makeApiClient(CoreV1Api);
|
||||
authApi = kubeConfig.makeApiClient(RbacAuthorizationV1Api);
|
||||
await api.createNamespace({
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await api.deleteNamespace('test');
|
||||
});
|
||||
|
||||
it('should fail when watch fails', async () => {
|
||||
const testConfig = await givenAServiceAccountThatCannotWatchPods(
|
||||
api,
|
||||
authApi,
|
||||
kubeConfig,
|
||||
);
|
||||
const options: KubernetesContainerRunnerOptions = {
|
||||
kubeConfig: testConfig,
|
||||
name,
|
||||
namespace: 'test',
|
||||
};
|
||||
const containerRunner = new KubernetesContainerRunner(options);
|
||||
const runOptions: RunContainerOptions = {
|
||||
imageName: 'golang:1.17',
|
||||
args: ['echo', 'hello world'],
|
||||
};
|
||||
await expect(containerRunner.runContainer(runOptions)).rejects.toThrow(
|
||||
'Kubernetes watch request failed with the following error message: Error: Forbidden',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function givenAServiceAccountThatCannotWatchPods(
|
||||
api: CoreV1Api,
|
||||
authApi: RbacAuthorizationV1Api,
|
||||
kubeConfig: KubeConfig,
|
||||
) {
|
||||
await Promise.all([
|
||||
api.createNamespacedServiceAccount('test', {
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
}),
|
||||
authApi.createNamespacedRole('test', {
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
apiGroups: ['batch'],
|
||||
verbs: ['create'],
|
||||
resources: ['jobs'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
authApi.createNamespacedRoleBinding('test', {
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
subjects: [
|
||||
{
|
||||
kind: 'ServiceAccount',
|
||||
name: 'test',
|
||||
},
|
||||
],
|
||||
roleRef: {
|
||||
apiGroup: 'rbac.authorization.k8s.io',
|
||||
kind: 'Role',
|
||||
name: 'test',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const token = (
|
||||
await api.createNamespacedServiceAccountToken('test', 'test', {
|
||||
spec: {
|
||||
audiences: [],
|
||||
},
|
||||
})
|
||||
).body.status?.token;
|
||||
const testConfig = new KubeConfig();
|
||||
testConfig.loadFromDefault();
|
||||
testConfig.addUser({
|
||||
name: 'test',
|
||||
token,
|
||||
});
|
||||
testConfig.addContext({
|
||||
name: 'test',
|
||||
cluster: kubeConfig.getCurrentCluster()!.name,
|
||||
user: 'test',
|
||||
});
|
||||
testConfig.setCurrentContext('test');
|
||||
return testConfig;
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PassThrough, Writable } from 'stream';
|
||||
import { ContainerRunner, RunContainerOptions } from './ContainerRunner';
|
||||
import {
|
||||
KubeConfig,
|
||||
BatchV1Api,
|
||||
V1Job,
|
||||
V1EnvVar,
|
||||
Log,
|
||||
HttpError,
|
||||
V1Status,
|
||||
V1VolumeMount,
|
||||
V1PodTemplateSpec,
|
||||
V1Pod,
|
||||
Watch,
|
||||
} from '@kubernetes/client-node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
* An existing Kubernetes volume that will be used as base for mounts.
|
||||
*
|
||||
* Every mount must start with the 'basePath'.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export type KubernetesContainerRunnerMountBase = {
|
||||
volumeName: string;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to create a {@link KubernetesContainerRunner}
|
||||
*
|
||||
* Kubernetes Jobs will be created on the provided 'namespace'
|
||||
* and their names will be prefixed with the provided 'name'.
|
||||
*
|
||||
* 'podTemplate' defines a Pod template for the Jobs. It has to include
|
||||
* a volume definition named as the {@link KubernetesContainerRunnerMountBase} 'volumeName'.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export type KubernetesContainerRunnerOptions = {
|
||||
kubeConfig: KubeConfig;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
mountBase?: KubernetesContainerRunnerMountBase;
|
||||
podTemplate?: V1PodTemplateSpec;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A {@link ContainerRunner} for Kubernetes.
|
||||
*
|
||||
* Runs containers leveraging Jobs on a Kubernetes cluster
|
||||
*
|
||||
* @public
|
||||
* @deprecated This class is deprecated and will be removed in a future release.
|
||||
*/
|
||||
export class KubernetesContainerRunner implements ContainerRunner {
|
||||
private readonly kubeConfig: KubeConfig;
|
||||
private readonly batchV1Api: BatchV1Api;
|
||||
private readonly log: Log;
|
||||
private readonly name: string;
|
||||
private readonly namespace: string;
|
||||
private readonly mountBase?: KubernetesContainerRunnerMountBase;
|
||||
private readonly podTemplate?: V1PodTemplateSpec;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly containerName = 'executor';
|
||||
|
||||
private getNamespace(kubeConfig: KubeConfig, namespace?: string): string {
|
||||
let _namespace = namespace;
|
||||
if (!_namespace) {
|
||||
_namespace = kubeConfig.getContextObject(
|
||||
kubeConfig.currentContext,
|
||||
)?.namespace;
|
||||
}
|
||||
if (!_namespace) {
|
||||
throw new Error('Cannot read current namespace from Kubernetes cluster');
|
||||
}
|
||||
return _namespace;
|
||||
}
|
||||
|
||||
private validateMountBase(
|
||||
mountBase: KubernetesContainerRunnerMountBase,
|
||||
podTemplate?: V1PodTemplateSpec,
|
||||
): KubernetesContainerRunnerMountBase {
|
||||
if (
|
||||
!podTemplate?.spec?.volumes?.filter(v => v.name === mountBase.volumeName)
|
||||
.length
|
||||
) {
|
||||
throw new Error(
|
||||
`A Pod template containing the volume ${mountBase.volumeName} is required`,
|
||||
);
|
||||
}
|
||||
if (!mountBase.basePath.endsWith('/')) {
|
||||
mountBase.basePath += '/';
|
||||
}
|
||||
return mountBase;
|
||||
}
|
||||
|
||||
constructor(options: KubernetesContainerRunnerOptions) {
|
||||
const { kubeConfig, name, namespace, mountBase, podTemplate, timeoutMs } =
|
||||
options;
|
||||
this.kubeConfig = kubeConfig;
|
||||
this.batchV1Api = kubeConfig.makeApiClient(BatchV1Api);
|
||||
this.log = new Log(kubeConfig);
|
||||
this.name = name;
|
||||
this.namespace = this.getNamespace(kubeConfig, namespace);
|
||||
if (mountBase) {
|
||||
this.mountBase = this.validateMountBase(mountBase, podTemplate);
|
||||
}
|
||||
this.podTemplate = podTemplate;
|
||||
this.timeoutMs = timeoutMs || 120 * 1000;
|
||||
}
|
||||
|
||||
async runContainer(options: RunContainerOptions) {
|
||||
const {
|
||||
imageName,
|
||||
command,
|
||||
args,
|
||||
logStream,
|
||||
mountDirs = {},
|
||||
workingDir,
|
||||
envVars = {},
|
||||
} = options;
|
||||
|
||||
const containerLogStream = new PassThrough();
|
||||
if (logStream) {
|
||||
containerLogStream.pipe(logStream, { end: false });
|
||||
}
|
||||
|
||||
const commandArr = typeof command === 'string' ? [command] : command;
|
||||
|
||||
const volumeMounts: V1VolumeMount[] = [];
|
||||
for (const [hostDir, containerDir] of Object.entries(mountDirs)) {
|
||||
if (!this.mountBase) {
|
||||
throw new Error(
|
||||
'A volumeName and a basePath must be configured to bind mount directories',
|
||||
);
|
||||
}
|
||||
if (!hostDir.startsWith(this.mountBase.basePath)) {
|
||||
throw new Error(
|
||||
`Mounted '${hostDir}' dir should be subdirectories of '${this.mountBase.basePath}'`,
|
||||
);
|
||||
}
|
||||
volumeMounts.push({
|
||||
name: this.mountBase.volumeName,
|
||||
mountPath: containerDir,
|
||||
subPath: hostDir.slice(this.mountBase.basePath.length),
|
||||
});
|
||||
}
|
||||
|
||||
const env = [];
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
env.push({
|
||||
name: key,
|
||||
value: value,
|
||||
} as V1EnvVar);
|
||||
}
|
||||
|
||||
const taskId = uuid();
|
||||
|
||||
// TODO find a way to merge recursively
|
||||
const mergedPodTemplate: V1PodTemplateSpec = {
|
||||
metadata: {
|
||||
...{
|
||||
labels: {
|
||||
task: taskId,
|
||||
},
|
||||
},
|
||||
...this.podTemplate?.metadata,
|
||||
},
|
||||
spec: {
|
||||
...{
|
||||
containers: [
|
||||
{
|
||||
name: this.containerName,
|
||||
image: imageName,
|
||||
command: commandArr,
|
||||
args: args,
|
||||
env: env,
|
||||
workingDir: workingDir,
|
||||
volumeMounts: volumeMounts,
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
...this.podTemplate?.spec,
|
||||
},
|
||||
};
|
||||
|
||||
const jobSpec: V1Job = {
|
||||
metadata: {
|
||||
generateName: `${this.name}-`,
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
ttlSecondsAfterFinished: 60,
|
||||
template: mergedPodTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
await this.runJob(jobSpec, taskId, containerLogStream);
|
||||
}
|
||||
|
||||
private handleError(err: any, errorCallback: (reason: any) => void) {
|
||||
if (err.code !== 'ECONNRESET' && err.message !== 'aborted') {
|
||||
errorCallback(
|
||||
handleKubernetesError(
|
||||
'Kubernetes watch request failed with the following error message:',
|
||||
err,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private watchPod(
|
||||
taskId: string,
|
||||
callback: (pod: V1Pod) => void,
|
||||
errorCallback: (reason: any) => void,
|
||||
): Promise<{ abort: () => void }> {
|
||||
const watch = new Watch(this.kubeConfig);
|
||||
const labelSelector = `task=${taskId}`;
|
||||
return watch.watch(
|
||||
`/api/v1/namespaces/${this.namespace}/pods`,
|
||||
{
|
||||
labelSelector,
|
||||
},
|
||||
(_, pod) => {
|
||||
callback(pod);
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
this.handleError(err, errorCallback);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private tailLogs(
|
||||
taskId: string,
|
||||
logStream: Writable,
|
||||
): { promise: Promise<void>; close: () => Promise<void> } {
|
||||
let log: Promise<{ abort: () => void }>;
|
||||
let req: Promise<{ abort: () => void }>;
|
||||
const watchPromise = new Promise<void>((_, reject) => {
|
||||
req = this.watchPod(
|
||||
taskId,
|
||||
pod => {
|
||||
if (
|
||||
log === undefined &&
|
||||
(pod.status?.phase === 'Running' ||
|
||||
pod.status?.phase === 'Succeeded' ||
|
||||
pod.status?.phase === 'Failed')
|
||||
) {
|
||||
log = this.log.log(
|
||||
this.namespace,
|
||||
pod.metadata?.name!,
|
||||
this.containerName,
|
||||
logStream,
|
||||
{ follow: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
const logPromise = new Promise<void>((resolve, _) => {
|
||||
if (!logStream.writableFinished) {
|
||||
logStream.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const close = async () => {
|
||||
if (req) {
|
||||
(await req).abort();
|
||||
}
|
||||
if (log) {
|
||||
(await log).abort();
|
||||
}
|
||||
};
|
||||
return { promise: Promise.race([watchPromise, logPromise]), close };
|
||||
}
|
||||
|
||||
private waitPod(taskId: string): {
|
||||
promise: Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
} {
|
||||
let req: Promise<{ abort: () => void }>;
|
||||
const promise = new Promise<void>(async (resolve, reject) => {
|
||||
req = this.watchPod(
|
||||
taskId,
|
||||
pod => {
|
||||
if (pod.status?.phase === 'Succeeded') {
|
||||
resolve();
|
||||
}
|
||||
if (pod.status?.phase === 'Failed') {
|
||||
reject(new Error('Container execution failed'));
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
const close = async () => {
|
||||
if (req) {
|
||||
(await req).abort();
|
||||
}
|
||||
};
|
||||
return { promise, close };
|
||||
}
|
||||
|
||||
private async createJob(jobSpec: V1Job): Promise<any> {
|
||||
return this.batchV1Api
|
||||
.createNamespacedJob(this.namespace, jobSpec)
|
||||
.catch(err => {
|
||||
throw handleKubernetesError(
|
||||
'Kubernetes Job creation failed with the following error message:',
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async runJob(
|
||||
jobSpec: V1Job,
|
||||
taskId: string,
|
||||
logStream: Writable,
|
||||
): Promise<any> {
|
||||
let timeout: NodeJS.Timeout;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
reject,
|
||||
this.timeoutMs,
|
||||
new Error(`Failed to complete in ${this.timeoutMs} ms`),
|
||||
);
|
||||
});
|
||||
|
||||
const { promise: waitPromise, close: waitClose } = this.waitPod(taskId);
|
||||
const { promise: tailPromise, close: tailClose } = this.tailLogs(
|
||||
taskId,
|
||||
logStream,
|
||||
);
|
||||
|
||||
const taskPromise = Promise.all([
|
||||
waitPromise,
|
||||
tailPromise,
|
||||
this.createJob(jobSpec),
|
||||
]).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return Promise.race([timeoutPromise, taskPromise])
|
||||
.finally(() => {
|
||||
return waitClose();
|
||||
})
|
||||
.finally(() => {
|
||||
return tailClose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleKubernetesError(message: string, err: Error): Error {
|
||||
if (err instanceof HttpError) {
|
||||
return new Error(`${message} ${(err.body as V1Status).message}`);
|
||||
}
|
||||
return new Error(`${message} ${err}`);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type {
|
||||
ContainerRunner,
|
||||
RunContainerOptions,
|
||||
PullOptions,
|
||||
} from './ContainerRunner';
|
||||
export { DockerContainerRunner } from './DockerContainerRunner';
|
||||
export type {
|
||||
KubernetesContainerRunnerOptions,
|
||||
KubernetesContainerRunnerMountBase,
|
||||
} from './KubernetesContainerRunner';
|
||||
export { KubernetesContainerRunner } from './KubernetesContainerRunner';
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common functionality library for Backstage backends
|
||||
*
|
||||
* @remarks
|
||||
* This package is deprecated and will be removed in a near future, so please follow the deprecated instructions for the exports you still use.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './deprecated';
|
||||
export * from './compat';
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
overridePackagePathResolution as _overridePackagePathResolution,
|
||||
OverridePackagePathResolutionOptions as _OverridePackagePathResolutionOptions,
|
||||
PackagePathResolutionOverride as _PackagePathResolutionOverride,
|
||||
} from '@backstage/backend-plugin-api/testUtils';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This function is deprecated and will be removed in future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `overridePackagePathResolution` function from the `@backstage/backend-plugin-api/testUtils` package instead.
|
||||
*/
|
||||
export const overridePackagePathResolution = _overridePackagePathResolution;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `OverridePackagePathResolutionOptions` type from the `@backstage/backend-plugin-api/testUtils` package instead.
|
||||
*/
|
||||
export type OverridePackagePathResolutionOptions =
|
||||
_OverridePackagePathResolutionOptions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This type is deprecated and will be removed in a future release, see https://github.com/backstage/backstage/issues/24493.
|
||||
* Please use the `PackagePathResolutionOverride` type from the `@backstage/backend-plugin-api/testUtils` package instead.
|
||||
*/
|
||||
export type PackagePathResolutionOverride = _PackagePathResolutionOverride;
|
||||
@@ -120,7 +120,7 @@
|
||||
"@aws-sdk/credential-providers": "^3.350.0",
|
||||
"@aws-sdk/types": "^3.347.0",
|
||||
"@backstage/backend-app-api": "workspace:^",
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-dev-utils": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
@@ -190,11 +190,15 @@
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/base64-stream": "^1.0.2",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/http-errors": "^2.0.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"@types/pg-format": "^1.0.5",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"aws-sdk-client-mock": "^4.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"msw": "^1.0.0",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-app-api": "workspace:^",
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-defaults": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-defaults": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
|
||||
@@ -277,7 +277,7 @@ d@^1:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/backend-app-api@workspace:packages/backend-app-api"
|
||||
dependencies:
|
||||
"@backstage/backend-common": "workspace:^"
|
||||
"@backstage/backend-common": "^0.25.0"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-tasks": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
@@ -330,7 +330,7 @@ d@^1:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/backend-app-api@workspace:packages/backend-app-api"
|
||||
dependencies:
|
||||
"@backstage/backend-common": "workspace:^"
|
||||
"@backstage/backend-common": "^0.25.0"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-tasks": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
|
||||
@@ -32,7 +32,6 @@ This does not create an actual dependency on these packages and does not bring i
|
||||
Rollup will extract the value of the version field in each package at build time without
|
||||
leaving any imports in place.
|
||||
*/
|
||||
import { version as backendCommon } from '../../../../packages/backend-common/package.json';
|
||||
import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json';
|
||||
import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json';
|
||||
import { version as cli } from '../../../../packages/cli/package.json';
|
||||
@@ -49,7 +48,6 @@ import { version as theme } from '../../../../packages/theme/package.json';
|
||||
import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json';
|
||||
|
||||
export const packageVersions: Record<string, string> = {
|
||||
'@backstage/backend-common': backendCommon,
|
||||
'@backstage/backend-defaults': backendDefaults,
|
||||
'@backstage/backend-plugin-api': backendPluginApi,
|
||||
'@backstage/backend-test-utils': backendTestUtils,
|
||||
|
||||
Reference in New Issue
Block a user