Merge pull request #26282 from backstage/freben/nocommon

[NBS 1.0] Remove `backend-common` package
This commit is contained in:
Fredrik Adelöw
2024-09-17 21:47:34 +02:00
committed by GitHub
136 changed files with 193 additions and 12592 deletions
+1 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
File diff suppressed because it is too large Load Diff
-42
View File
@@ -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)
```
-561
View File
@@ -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;
```
-10
View File
@@ -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
-265
View File
@@ -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[];
}>;
};
};
}
-24
View File
@@ -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 |
-157
View File
@@ -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"
}
-17
View File
@@ -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),
};
}
-17
View File
@@ -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';
-27
View File
@@ -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';
-17
View File
@@ -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 {};
-43
View File
@@ -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;
+5 -1
View File
@@ -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:^",
+1 -1
View File
@@ -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:^"
+1 -1
View File
@@ -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:^",
-2
View File
@@ -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,