feat: add database manager with per plugin config

This commit introduces:
- a new backwards compatible database manager which allows the end-user
  to set global and per plugin database configuration.
- an early iteration of a database connector interface.
- provides helper functions for each of the database connectors to
  meet the new interface.

Signed-off-by: Minn Soe <contributions@minn.io>
This commit is contained in:
Minn Soe
2021-05-17 18:16:06 +01:00
parent 399abf9670
commit 772dbdb511
14 changed files with 1159 additions and 226 deletions
+50
View File
@@ -0,0 +1,50 @@
---
'example-backend': minor
'@backstage/backend-common': minor
---
Introduces `PluginConnectionDatabaseManager`, a backwards compatible database
connection manager which allows developers to configure database connections on
a per plugin basis.
The `backend.database` config path allows you to set `prefix` to use an
alternate prefix for automatically generated database names, the default is
`backstage_plugin_`. Use `backend.database.plugin.<pluginId>` to set plugin
specific database connection configuration, e.g.
```yaml
backend:
database:
client: 'pg',
prefix: 'custom_prefix_'
connection:
host: 'localhost'
user: 'foo'
password: 'bar'
plugin:
catalog:
connection:
database: 'database_name_overriden'
scaffolder:
client: 'sqlite3'
connection: ':inmemory'
```
Existing backstage installations can be migrated by swapping out the database
manager under `packages/backend/src/index.ts` as shown below:
```diff
import {
- SingleConnectionDatabaseManager,
+ PluginConnectionDatabaseManager,
} from '@backstage/backend-common';
// ...
function makeCreateEnv(config: Config) {
// ...
- const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
+ const databaseManager = PluginConnectionDatabaseManager.fromConfig(config);
// ...
}
```
@@ -0,0 +1,107 @@
---
id: configuring-plugin-databases
title: Configuring Plugin Specific Databases
# prettier-ignore
description: Guide on how to use predefined databases for each plugin.
---
There are occasions where it may be difficult to deploy Backstage with
automatically created databases in production due to access control or other
restrictions. For example, your infrastructure might be defined as code using
tools such as Terraform or AWS CloudFormation where the name of each database is
defined, created and assigned explicitly.
`@backstage/backend-common` provides an alternate database manager which allows
you to set the client and database connection on a per plugin basis. This means
that you can do selectively run certain plugins in memory with `sqlite3`, set
different connection config including the name of the database and more.
There are two additional configuration options for this database manager:
- **`backend.database.prefix`:** is used to override the default
`backstage_plugin_` prefix which is used to generate a database name when it
is not explicitly set for that plugin.
- **`backend.database.plugin.<pluginId>`:** is used to define a `client` and
`connection` block for the plugin matching the `pluginId`, e.g. `catalog` is
the `pluginId` for the catalog plugin and any configuration defined under that
block is specific to that plugin.
## Install Database Drivers
If you intend to use both `postgres` and `sqlite3`, you need to make sure the
appropriate database drivers are installed in your `backend` package.
```shell
cd packages/backend
# install pg if you need postgres
yarn add pg
# install sqlite3 if you intend to set it as the client
yarn add sqlite3
```
## Add Configuration
To override the default prefix, `backstage_plugin_`, set
`backend.database.prefix` as shown below. This will use databases such as
`my_company_catalog` and `my_company_auth` instead of `backstage_plugin_catalog`
and `backstage_plugin_auth`.
```yaml
backend:
database:
client: pg
prefix: my_company_
connection:
host: localhost
user: postgres
password: password
plugin:
code-coverage:
connection:
database: pg_code_coverage_set_by_user
```
In the example above, the `code-coverage` plugin will use the same connection
configuration defined under `database.connection` and use
`pg_code_coverage_set_by_user` instead of `my_company_code-coverage` which would
be automatically generated if a plugin configuration wasn't explicitly set.
## Integrate `PluginConnectionDatabaseManager` into `backend`
The `SingleConnectionDatabaseManager` used by default should be replaced with
the `PluginConnectionDatabaseManager` in your `packages/backend/src/index.ts`
file. Import the manager and replace the `.fromConfig` call as shown below:
```diff
import {
- SingleConnectionDatabaseManager,
+ PluginConnectionDatabaseManager,
} from '@backstage/backend-common';
// ...
function makeCreateEnv(config: Config) {
// ...
- const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
+ const databaseManager = PluginConnectionDatabaseManager.fromConfig(config);
// ...
}
```
## Check Your Databases
The `PluginConnectionDatabaseManager` preserves the behaviour of the
`SingleConnectionDatabaseManager`. If the database does not exist, it will
attempt to create it. You should ensure the databases that you configure exists
and that the connection details have the appropriate permissions to work with
each of the given databases if you are using this database manager to set the
database name upfront. If each database needs its own connection username,
password or host - you may set them under the plugin's `connection` block.
`sqlite3` databases do not need to be created upfront as with the existing
database manager.
Your Backstage App can now use different database clients and configuration per
plugin!
+255 -204
View File
@@ -3,7 +3,6 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AzureIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
@@ -31,95 +30,120 @@ import { Writable } from 'stream';
// @public (undocumented)
export class AzureUrlReader implements UrlReader {
constructor(integration: AzureIntegration, deps: {
treeResponseFactory: ReadTreeResponseFactory;
});
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
constructor(
integration: AzureIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export class BitbucketUrlReader implements UrlReader {
constructor(integration: BitbucketIntegration, deps: {
treeResponseFactory: ReadTreeResponseFactory;
});
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
constructor(
integration: BitbucketIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export interface CacheClient {
delete(key: string): Promise<void>;
get(key: string): Promise<JsonValue | undefined>;
set(key: string, value: JsonValue, options?: CacheSetOptions): Promise<void>;
delete(key: string): Promise<void>;
get(key: string): Promise<JsonValue | undefined>;
set(key: string, value: JsonValue, options?: CacheSetOptions): Promise<void>;
}
// @public
export class CacheManager {
forPlugin(pluginId: string): PluginCacheManager;
static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager;
}
forPlugin(pluginId: string): PluginCacheManager;
static fromConfig(config: Config, options?: CacheManagerOptions): CacheManager;
}
// @public (undocumented)
export const coloredFormat: winston.Logform.Format;
// @public (undocumented)
export interface ContainerRunner {
// (undocumented)
runContainer(opts: RunContainerOptions): Promise<void>;
// (undocumented)
runContainer(opts: RunContainerOptions): Promise<void>;
}
// @public @deprecated
export const createDatabase: typeof createDatabaseClient;
// @public
export function createDatabaseClient(dbConfig: Config, overrides?: Partial<Knex.Config>): Knex<any, unknown[]>;
export function createDatabaseClient(
dbConfig: Config,
overrides?: Partial<Knex.Config>,
): Knex<any, unknown[]>;
// @public (undocumented)
export function createRootLogger(options?: winston.LoggerOptions, env?: NodeJS.ProcessEnv): winston.Logger;
export function createRootLogger(
options?: winston.LoggerOptions,
env?: NodeJS.ProcessEnv,
): winston.Logger;
// @public
export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl;
// @public (undocumented)
export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise<express.Router>;
export function createStatusCheckRouter(
options: StatusCheckRouterOptions,
): Promise<express.Router>;
// @public (undocumented)
export class DockerContainerRunner implements ContainerRunner {
constructor({ dockerClient }: {
dockerClient: Docker;
});
// (undocumented)
runContainer({ imageName, command, args, logStream, mountDirs, workingDir, envVars, }: RunContainerOptions): Promise<void>;
constructor({ dockerClient }: { dockerClient: Docker });
// (undocumented)
runContainer({
imageName,
command,
args,
logStream,
mountDirs,
workingDir,
envVars,
}: RunContainerOptions): Promise<void>;
}
// @public
export function ensureDatabaseExists(dbConfig: Config, ...databases: Array<string>): Promise<void>;
export function ensureDatabaseExists(
dbConfig: Config,
...databases: Array<string>
): Promise<void>;
// @public
export function errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
export function errorHandler(
options?: ErrorHandlerOptions,
): ErrorRequestHandler;
// @public (undocumented)
export type ErrorHandlerOptions = {
showStackTraces?: boolean;
logger?: Logger;
logClientErrors?: boolean;
showStackTraces?: boolean;
logger?: Logger;
logClientErrors?: boolean;
};
// @public (undocumented)
@@ -130,120 +154,141 @@ export function getVoidLogger(): winston.Logger;
// @public (undocumented)
export class Git {
// (undocumented)
add({ dir, filepath, }: {
dir: string;
filepath: string;
}): Promise<void>;
// (undocumented)
addRemote({ dir, url, remote, }: {
dir: string;
remote: string;
url: string;
}): Promise<void>;
// (undocumented)
clone({ url, dir, ref, }: {
url: string;
dir: string;
ref?: string;
}): Promise<void>;
// (undocumented)
commit({ dir, message, author, committer, }: {
dir: string;
message: string;
author: {
name: string;
email: string;
};
committer: {
name: string;
email: string;
};
}): Promise<string>;
// (undocumented)
currentBranch({ dir, fullName, }: {
dir: string;
fullName?: boolean;
}): Promise<string | undefined>;
// (undocumented)
fetch({ dir, remote, }: {
dir: string;
remote?: string;
}): Promise<void>;
// (undocumented)
static fromAuth: ({ username, password, logger, }: {
username?: string | undefined;
password?: string | undefined;
logger?: Logger | undefined;
}) => Git;
// (undocumented)
init({ dir }: {
dir: string;
}): Promise<void>;
// (undocumented)
merge({ dir, theirs, ours, author, committer, }: {
dir: string;
theirs: string;
ours?: string;
author: {
name: string;
email: string;
};
committer: {
name: string;
email: string;
};
}): Promise<MergeResult>;
// (undocumented)
push({ dir, remote }: {
dir: string;
remote: string;
}): Promise<PushResult>;
// (undocumented)
readCommit({ dir, sha, }: {
dir: string;
sha: string;
}): Promise<ReadCommitResult>;
// (undocumented)
resolveRef({ dir, ref, }: {
dir: string;
ref: string;
}): Promise<string>;
// (undocumented)
add({ dir, filepath }: { dir: string; filepath: string }): Promise<void>;
// (undocumented)
addRemote({
dir,
url,
remote,
}: {
dir: string;
remote: string;
url: string;
}): Promise<void>;
// (undocumented)
clone({
url,
dir,
ref,
}: {
url: string;
dir: string;
ref?: string;
}): Promise<void>;
// (undocumented)
commit({
dir,
message,
author,
committer,
}: {
dir: string;
message: string;
author: {
name: string;
email: string;
};
committer: {
name: string;
email: string;
};
}): Promise<string>;
// (undocumented)
currentBranch({
dir,
fullName,
}: {
dir: string;
fullName?: boolean;
}): Promise<string | undefined>;
// (undocumented)
fetch({ dir, remote }: { dir: string; remote?: string }): Promise<void>;
// (undocumented)
static fromAuth: ({
username,
password,
logger,
}: {
username?: string | undefined;
password?: string | undefined;
logger?: Logger | undefined;
}) => Git;
// (undocumented)
init({ dir }: { dir: string }): Promise<void>;
// (undocumented)
merge({
dir,
theirs,
ours,
author,
committer,
}: {
dir: string;
theirs: string;
ours?: string;
author: {
name: string;
email: string;
};
committer: {
name: string;
email: string;
};
}): Promise<MergeResult>;
// (undocumented)
push({ dir, remote }: { dir: string; remote: string }): Promise<PushResult>;
// (undocumented)
readCommit({
dir,
sha,
}: {
dir: string;
sha: string;
}): Promise<ReadCommitResult>;
// (undocumented)
resolveRef({ dir, ref }: { dir: string; ref: string }): Promise<string>;
}
// @public
export class GithubUrlReader implements UrlReader {
constructor(integration: GitHubIntegration, deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: GithubCredentialsProvider;
});
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
constructor(
integration: GitHubIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: GithubCredentialsProvider;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public (undocumented)
export class GitlabUrlReader implements UrlReader {
constructor(integration: GitLabIntegration, deps: {
treeResponseFactory: ReadTreeResponseFactory;
});
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
constructor(
integration: GitLabIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
@@ -254,32 +299,32 @@ export function notFoundHandler(): RequestHandler;
// @public
export type PluginCacheManager = {
getClient: (options?: ClientOptions) => CacheClient;
getClient: (options?: ClientOptions) => CacheClient;
};
// @public
export interface PluginDatabaseManager {
getClient(): Promise<Knex>;
getClient(): Promise<Knex>;
}
// @public
export type PluginEndpointDiscovery = {
getBaseUrl(pluginId: string): Promise<string>;
getExternalBaseUrl(pluginId: string): Promise<string>;
getBaseUrl(pluginId: string): Promise<string>;
getExternalBaseUrl(pluginId: string): Promise<string>;
};
// @public (undocumented)
export type ReadTreeResponse = {
files(): Promise<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
etag: string;
files(): Promise<ReadTreeResponseFile[]>;
archive(): Promise<NodeJS.ReadableStream>;
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
etag: string;
};
// @public
export type ReadTreeResponseFile = {
path: string;
content(): Promise<Buffer>;
path: string;
content(): Promise<Buffer>;
};
// @public
@@ -290,37 +335,37 @@ export function resolvePackagePath(name: string, ...paths: string[]): string;
// @public (undocumented)
export type RunContainerOptions = {
imageName: string;
command?: string | string[];
args: string[];
logStream?: Writable;
mountDirs?: Record<string, string>;
workingDir?: string;
envVars?: Record<string, string>;
imageName: string;
command?: string | string[];
args: string[];
logStream?: Writable;
mountDirs?: Record<string, string>;
workingDir?: string;
envVars?: Record<string, string>;
};
// @public
export type SearchResponse = {
files: SearchResponseFile[];
etag: string;
files: SearchResponseFile[];
etag: string;
};
// @public
export type SearchResponseFile = {
url: string;
content(): Promise<Buffer>;
url: string;
content(): Promise<Buffer>;
};
// @public (undocumented)
export type ServiceBuilder = {
loadConfig(config: ConfigReader): ServiceBuilder;
setPort(port: number): ServiceBuilder;
setHost(host: string): ServiceBuilder;
setLogger(logger: Logger): ServiceBuilder;
enableCors(options: cors.CorsOptions): ServiceBuilder;
setHttpsSettings(settings: HttpsSettings): ServiceBuilder;
addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
start(): Promise<Server>;
loadConfig(config: ConfigReader): ServiceBuilder;
setPort(port: number): ServiceBuilder;
setHost(host: string): ServiceBuilder;
setLogger(logger: Logger): ServiceBuilder;
enableCors(options: cors.CorsOptions): ServiceBuilder;
setHttpsSettings(settings: HttpsSettings): ServiceBuilder;
addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
start(): Promise<Server>;
};
// @public (undocumented)
@@ -328,52 +373,58 @@ export function setRootLogger(newLogger: winston.Logger): void;
// @public
export class SingleConnectionDatabaseManager {
forPlugin(pluginId: string): PluginDatabaseManager;
static fromConfig(config: Config): SingleConnectionDatabaseManager;
}
forPlugin(pluginId: string): PluginDatabaseManager;
static fromConfig(config: Config): SingleConnectionDatabaseManager;
}
// @public
export class SingleHostDiscovery implements PluginEndpointDiscovery {
static fromConfig(config: Config, options?: {
basePath?: string;
}): SingleHostDiscovery;
// (undocumented)
getBaseUrl(pluginId: string): Promise<string>;
// (undocumented)
getExternalBaseUrl(pluginId: string): Promise<string>;
}
static fromConfig(
config: Config,
options?: {
basePath?: string;
},
): SingleHostDiscovery;
// (undocumented)
getBaseUrl(pluginId: string): Promise<string>;
// (undocumented)
getExternalBaseUrl(pluginId: string): Promise<string>;
}
// @public (undocumented)
export type StatusCheck = () => Promise<any>;
// @public
export function statusCheckHandler(options?: StatusCheckHandlerOptions): Promise<RequestHandler>;
export function statusCheckHandler(
options?: StatusCheckHandlerOptions,
): Promise<RequestHandler>;
// @public (undocumented)
export interface StatusCheckHandlerOptions {
statusCheck?: StatusCheck;
statusCheck?: StatusCheck;
}
// @public
export type UrlReader = {
read(url: string): Promise<Buffer>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
read(url: string): Promise<Buffer>;
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
};
// @public
export class UrlReaders {
static create({ logger, config, factories }: CreateOptions): UrlReader;
static default({ logger, config, factories }: CreateOptions): UrlReader;
static create({ logger, config, factories }: CreateOptions): UrlReader;
static default({ logger, config, factories }: CreateOptions): UrlReader;
}
// @public
export function useHotCleanup(_module: NodeModule, cancelEffect: () => void): void;
export function useHotCleanup(
_module: NodeModule,
cancelEffect: () => void,
): void;
// @public
export function useHotMemoize<T>(_module: NodeModule, valueFactory: () => T): T;
// (No @packageDocumentation comment for this package)
```
+29
View File
@@ -14,6 +14,23 @@
* limitations under the License.
*/
export type PluginDatabaseConfig =
| {
/** Database client to use for plugin. */
client?: 'sqlite3';
/** Database connection to use with plugin. */
connection?: ':memory:' | string | { filename: string };
}
| {
/** Database client to use for plugin. */
client?: 'pg';
/**
* PostgreSQL connection string or knex configuration object for plugin.
* @secret
*/
connection?: string | object;
};
export interface Config {
app: {
baseUrl: string; // defined in core, but repeated here without doc
@@ -58,6 +75,12 @@ export interface Config {
| {
client: 'sqlite3';
connection: ':memory:' | string | { filename: string };
/** Optional sqlite3 database filename prefix. */
prefix?: string;
/** Override database config per plugin. */
plugin?: {
[pluginId: string]: PluginDatabaseConfig;
};
}
| {
client: 'pg';
@@ -66,6 +89,12 @@ export interface Config {
* @secret
*/
connection: string | object;
/** Optional PostgreSQL database prefix. */
prefix?: string;
/** Override database config per plugin. */
plugin?: {
[pluginId: string]: PluginDatabaseConfig;
};
};
/** Cache connection configuration, select cache type using the `store` field */
@@ -0,0 +1,325 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { omit } from 'lodash';
import { createDatabaseClient, ensureDatabaseExists } from './connection';
import { PluginConnectionDatabaseManager } from './PluginConnection';
jest.mock('./connection', () => ({
...jest.requireActual('./connection'),
createDatabaseClient: jest.fn(),
ensureDatabaseExists: jest.fn(),
}));
describe('PluginConnectionDatabaseManager', () => {
// This is similar to the ts-jest `mocked` helper.
const mocked = (f: Function) => f as jest.Mock;
afterEach(() => jest.resetAllMocks());
describe('PluginConnectionDatabaseManager.fromConfig', () => {
const backendConfig = {
backend: {
database: {
client: 'pg',
connection: {
host: 'localhost',
user: 'foo',
password: 'bar',
database: 'foodb',
},
},
},
};
const defaultConfig = () => new ConfigReader(backendConfig);
it('accesses the backend.database key', () => {
const getConfig = jest.fn();
const config = defaultConfig();
config.getConfig = getConfig;
PluginConnectionDatabaseManager.fromConfig(config);
expect(getConfig.mock.calls[0][0]).toEqual('backend.database');
});
});
describe('PluginConnectionDatabaseManager.forPlugin', () => {
const config = {
backend: {
database: {
client: 'pg',
prefix: 'test_prefix_',
connection: {
host: 'localhost',
user: 'foo',
password: 'bar',
database: 'foodb',
},
plugin: {
testdbname: {
connection: {
database: 'database_name_overriden',
},
},
differentclient: {
client: 'sqlite3',
connection: {
filename: 'plugin_with_different_client',
},
},
differentclientconnstring: {
client: 'sqlite3',
connection: ':inmemory:',
},
stringoverride: {
connection: 'postgresql://testuser:testpass@acme:5432/userdbname',
},
},
},
},
};
const manager = PluginConnectionDatabaseManager.fromConfig(
new ConfigReader(config),
);
it('connects to a plugin database using default config', async () => {
const pluginId = 'pluginwithoutconfig';
await manager.forPlugin(pluginId).getClient();
expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(1);
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, overrides] = mockCalls[0];
// default config should be passed through to underlying connector
expect(baseConfig.get()).toMatchObject({
client: 'pg',
connection: omit(config.backend.database.connection, ['database']),
});
// override using database name generated from pluginId and prefix
expect(overrides).toMatchObject({
connection: {
database: `${config.backend.database.prefix}${pluginId}`,
},
});
});
it('provides a plugin db which uses components from top level connection string', async () => {
const testManager = PluginConnectionDatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'pg',
connection: 'postgresql://foo:bar@acme:5432/foodb',
},
},
}),
);
await testManager.forPlugin('pluginwithoutconfig').getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, overrides] = mockCalls[0];
// parsed connection string **without** db name should be passed through
expect(baseConfig.get()).toMatchObject({
connection: {
host: 'acme',
user: 'foo',
password: 'bar',
port: '5432',
},
});
// we expect a pg database name override with ${prefix} followed by pluginId
expect(overrides).toHaveProperty(
'connection.database',
expect.stringContaining('pluginwithoutconfig'),
);
});
it('uses top level sqlite database filename if plugin config is not present', async () => {
const testManager = PluginConnectionDatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'sqlite3',
connection: 'some-file-path',
},
},
}),
);
await testManager.forPlugin('pluginwithoutconfig').getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [_, overrides] = mockCalls[0];
expect(overrides).toHaveProperty(
'connection.filename',
expect.stringContaining('some-file-path'),
);
});
it('provides an inmemory sqlite database if top level is also inmemory and plugin config is not present', async () => {
const testManager = PluginConnectionDatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'sqlite3',
connection: ':inmemory:',
},
},
}),
);
await testManager.forPlugin('pluginwithoutconfig').getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [_, overrides] = mockCalls[0];
expect(overrides).toHaveProperty(
'connection.filename',
expect.stringContaining(':inmemory:'),
);
});
it('connects to a plugin database using a specific database name', async () => {
// testdbname.connection.database is set in config
await manager.forPlugin('testdbname').getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [_baseConfig, overrides] = mockCalls[0];
// simple case where only database name is overriden
expect(overrides).toMatchObject({
connection: {
database: 'database_name_overriden',
},
});
});
it('ensure plugin specific database is created', async () => {
const pluginId = 'testdbname';
// testdbname.connection.database is set in config
await manager.forPlugin(pluginId).getClient();
const mockCalls = mocked(ensureDatabaseExists).mock.calls.splice(-1);
const [_, dbname] = mockCalls[0];
expect(dbname).toEqual(
config.backend.database.plugin[pluginId].connection.database,
);
});
it('provides different plugins with their own databases', async () => {
await manager.forPlugin('plugin1').getClient();
await manager.forPlugin('plugin2').getClient();
expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(2);
const mockCalls = mocked(createDatabaseClient).mock.calls;
const [plugin1CallArgs, plugin2CallArgs] = mockCalls;
// database name overrides should be different
expect(plugin1CallArgs[1].connection.database).not.toEqual(
plugin2CallArgs[1].connection.database,
);
});
it('uses plugin connection as base if default client is different from plugin client', async () => {
const pluginId = 'differentclient';
await manager.forPlugin(pluginId).getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, _overrides] = mockCalls[0];
// plugin connection should be used as base config, client is different
expect(baseConfig.get()).toMatchObject({
client: 'sqlite3',
connection: config.backend.database.plugin[pluginId].connection,
});
});
it('provides database client specific base and override when client set under plugin', async () => {
const pluginId = 'differentclient';
await manager.forPlugin(pluginId).getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, overrides] = mockCalls[0];
// plugin client should be sqlite3
expect(baseConfig.get().client).toEqual('sqlite3');
// sqlite3 uses 'filename' instead of 'database'
expect(overrides).toHaveProperty('connection.filename');
});
it('provides database client specific base from plugin connection string when client set under plugin', async () => {
const pluginId = 'differentclientconnstring';
await manager.forPlugin(pluginId).getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, overrides] = mockCalls[0];
expect(baseConfig.get().client).toEqual('sqlite3');
expect(overrides).toHaveProperty('connection.filename', ':inmemory:');
});
it('generates a database name override when prefix is not explicitly set', async () => {
const testManager = PluginConnectionDatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'pg',
connection: {
host: 'localhost',
user: 'foo',
password: 'bar',
database: 'foodb',
},
},
},
}),
);
await testManager.forPlugin('testplugin').getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [_baseConfig, overrides] = mockCalls[0];
expect(overrides).toHaveProperty(
'connection.database',
expect.stringContaining(PluginConnectionDatabaseManager.DEFAULT_PREFIX),
);
});
it('uses values from plugin connection string if top level client should be used', async () => {
const pluginId = 'stringoverride';
await manager.forPlugin(pluginId).getClient();
const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1);
const [baseConfig, overrides] = mockCalls[0];
// plugin client should be pg
expect(baseConfig.get().client).toEqual('pg');
expect(overrides).toHaveProperty(
'connection.database',
expect.stringContaining('userdbname'),
);
});
});
});
@@ -0,0 +1,160 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Knex } from 'knex';
import { omit } from 'lodash';
import { Config, ConfigReader } from '@backstage/config';
import {
createDatabaseClient,
ensureDatabaseExists,
createNameOverride,
normalizeConnection,
} from './connection';
import { PluginDatabaseManager } from './types';
function pluginPath(pluginId: string): string {
return `plugin.${pluginId}`;
}
export class PluginConnectionDatabaseManager {
static readonly DEFAULT_PREFIX = 'backstage_plugin_';
/**
* Creates a PluginConnectionDatabaseManager from `backend.database` config.
*
* The database manager allows the user to set connection and client settings on a per pluginId
* basis by defining a database config block under `plugin.<pluginId>` in addition to top level
* defaults. Optionally, a user may set `prefix` which is used to prefix generated database
* names if config is not provided.
*
* @param config The loaded application configuration.
*/
static fromConfig(config: Config): PluginConnectionDatabaseManager {
return new PluginConnectionDatabaseManager(
config.getConfig('backend.database'),
);
}
private constructor(private readonly config: Config) {}
/**
* Generates a PluginDatabaseManager for consumption by plugins.
*
* @param pluginId The plugin that the database manager should be created for. Plugin names should be unique
* as they are used to look up database config overrides under `backend.database.plugin`.
*/
forPlugin(pluginId: string): PluginDatabaseManager {
const _this = this;
return {
getClient(): Promise<Knex> {
return _this.getDatabase(pluginId);
},
};
}
/**
* Provides the canonical database name for a given pluginId.
*
* This method provides the effective database name which is determined using global
* and plugin specific database config. If no explicit database name is configured,
* this method will provide a generated name which is the pluginId prefixed using
* the value from `PluginConnectionDatabaseManager.DEFAULT_PREFIX`.
*
* @param pluginId Lookup the database name for given plugin
* */
getDatabaseName(pluginId: string): string {
const pluginConfig: Config = this.getConfigForPlugin(pluginId);
// determine root sqlite config to pass through as this is a special case
const rootConnection = this.config.get('connection');
const rootSqliteName =
typeof rootConnection === 'string'
? rootConnection
: this.config.getOptionalString('connection.filename') ?? ':inmemory:';
const prefix =
this.config.getOptionalString('prefix') ??
PluginConnectionDatabaseManager.DEFAULT_PREFIX;
const isSqlite = this.config.getString('client') === 'sqlite3';
return (
// attempt to lookup pg and mysql database name
pluginConfig.getOptionalString('connection.database') ??
// attempt to lookup sqlite3 database file name
pluginConfig.getOptionalString('connection.filename') ??
// if root is sqlite - attempt to use top level connection, fallback to :inmemory:
(isSqlite ? rootSqliteName : null) ??
// generate a database name using prefix and pluginId
`${prefix}${pluginId}`
);
}
/**
* Provides a base database connector config by merging different config sources.
*
* This method provides a baseConfig for a database connector without the target
* database's name property ('database', 'filename'). The client type is determined
* by plugin specific config which uses the default as the fallback.
*
* If the client type is the same as the plugin or not specified, the global
* connection config will be extended with plugin specific config.
*
* @param pluginId The plugin that the database baseConfig should correspond to
* */
private getConfigForPlugin(pluginId: string): Config {
const pluginConfig = this.config.getOptionalConfig(pluginPath(pluginId));
const baseClient = this.config.getString('client');
const client = pluginConfig?.getOptionalString('client') ?? baseClient;
const baseConnection = normalizeConnection(
this.config.get('connection'),
baseClient,
);
const connection = normalizeConnection(
pluginConfig?.getOptional('connection') ?? {},
client,
);
return new ConfigReader({
client,
connection: {
// if same client type, extend original connection config without dbname config
...(client === baseClient
? omit(baseConnection, ['database', 'filename'])
: {}),
...connection,
},
});
}
private async getDatabase(pluginId: string): Promise<Knex> {
const pluginConfig = this.getConfigForPlugin(pluginId);
await ensureDatabaseExists(pluginConfig, this.getDatabaseName(pluginId));
return createDatabaseClient(
pluginConfig,
this.getDatabaseOverrides(pluginId),
);
}
private getDatabaseOverrides(pluginId: string): Knex.Config {
return createNameOverride(
this.getConfigForPlugin(pluginId).get('client'),
this.getDatabaseName(pluginId),
);
}
}
@@ -15,7 +15,11 @@
*/
import { ConfigReader } from '@backstage/config';
import { createDatabaseClient } from './connection';
import {
createDatabaseClient,
createNameOverride,
parseConnectionString,
} from './connection';
describe('database connection', () => {
describe('createDatabaseClient', () => {
@@ -103,4 +107,49 @@ describe('database connection', () => {
).toThrowError();
});
});
describe('createNameOverride', () => {
it('returns Knex config for postgres', () => {
expect(createNameOverride('pg', 'testpg')).toHaveProperty(
'connection.database',
'testpg',
);
});
it('returns Knex config for sqlite', () => {
expect(createNameOverride('sqlite3', 'testsqlite')).toHaveProperty(
'connection.filename',
'testsqlite',
);
});
it('returns Knex config for mysql', () => {
expect(createNameOverride('mysql', 'testmysql')).toHaveProperty(
'connection.database',
'testmysql',
);
});
it('throws an error for unknown connection', () => {
expect(() => createNameOverride('unknown', 'testname')).toThrowError();
});
});
describe('parseConnectionString', () => {
it('returns parsed Knex.StaticConnectionConfig for postgres', () => {
expect(
parseConnectionString('postgresql://foo:bar@acme:5432/foodb', 'pg'),
).toHaveProperty('database', 'foodb');
});
it('returns parsed Knex.StaticConnectionConfig for mysql2', () => {
expect(
parseConnectionString('mysql://foo:bar@acme:3306/foodb', 'mysql2'),
).toHaveProperty('database', 'foodb');
});
it('throws an error if client hint is not provided', () => {
expect(() => parseConnectionString('sqlite://')).toThrow();
});
});
});
@@ -14,14 +14,30 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { Config, JsonObject } from '@backstage/config';
import { InputError } from '@backstage/errors';
import knexFactory, { Knex } from 'knex';
import { mergeDatabaseConfig } from './config';
import { createMysqlDatabaseClient, ensureMysqlDatabaseExists } from './mysql';
import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres';
import { createSqliteDatabaseClient } from './sqlite3';
import { DatabaseConnector } from './connector';
type DatabaseClient = 'pg' | 'sqlite3' | string;
import { mysqlConnector } from './mysql';
import { pgConnector } from './postgres';
import { sqlite3Connector } from './sqlite3';
type DatabaseClient = 'pg' | 'sqlite3' | 'mysql' | 'mysql2' | string;
/**
* Mapping of client type to supported database connectors
*
* Database connectors can be aliased here, for example mysql2 uses
* the same connector as mysql.
* */
const ConnectorMapping: Record<DatabaseClient, DatabaseConnector> = {
pg: pgConnector,
sqlite3: sqlite3Connector,
mysql: mysqlConnector,
mysql2: mysqlConnector,
};
/**
* Creates a knex database connection
@@ -35,15 +51,10 @@ export function createDatabaseClient(
) {
const client: DatabaseClient = dbConfig.getString('client');
if (client === 'pg') {
return createPgDatabaseClient(dbConfig, overrides);
} else if (client === 'mysql' || client === 'mysql2') {
return createMysqlDatabaseClient(dbConfig, overrides);
} else if (client === 'sqlite3') {
return createSqliteDatabaseClient(dbConfig, overrides);
}
return knexFactory(mergeDatabaseConfig(dbConfig.get(), overrides));
return (
ConnectorMapping[client]?.createClient(dbConfig, overrides) ??
knexFactory(mergeDatabaseConfig(dbConfig.get(), overrides))
);
}
/**
@@ -61,11 +72,63 @@ export async function ensureDatabaseExists(
) {
const client: DatabaseClient = dbConfig.getString('client');
if (client === 'pg') {
return ensurePgDatabaseExists(dbConfig, ...databases);
} else if (client === 'mysql' || client === 'mysql2') {
return ensureMysqlDatabaseExists(dbConfig, ...databases);
return ConnectorMapping[client]?.ensureDatabaseExists?.(
dbConfig,
...databases,
);
}
/**
* Provides a Knex.Config object with the provided database name for a given client.
* */
export function createNameOverride(
client: string,
name: string,
): Partial<Knex.Config> {
try {
return ConnectorMapping[client].createNameOverride(name);
} catch (e) {
throw new InputError(
`Unable to create database name override for '${client}' connector`,
e,
);
}
}
/**
* Parses a connection string for a given client and provides a connection config.
* */
export function parseConnectionString(
connectionString: string,
client?: string,
): Knex.StaticConnectionConfig {
if (typeof client === 'undefined' || client === null) {
throw new InputError(
'Database connection string client type auto-detection is not yet supported.',
);
}
return undefined;
try {
return ConnectorMapping[client].parseConnectionString(connectionString);
} catch (e) {
throw new InputError(
`Unable to parse connection string for '${client}' connector`,
);
}
}
/**
* Normalizes a connection config or string into an object which can be passed to Knex.
* */
export function normalizeConnection(
connection: Knex.StaticConnectionConfig | JsonObject | string,
client: string,
): Record<string, any> {
if (typeof connection === 'undefined' || connection === null) {
return {};
}
return typeof connection === 'string' || connection instanceof String
? parseConnectionString(connection as string, client)
: connection;
}
@@ -0,0 +1,30 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { Knex } from 'knex';
export interface DatabaseConnector {
createClient(dbConfig: Config, overrides?: Partial<Knex.Config>): Knex;
createNameOverride(name: string): Partial<Knex.Config>;
parseConnectionString(
connectionString: string,
client?: string,
): Knex.StaticConnectionConfig;
ensureDatabaseExists?(
dbConfig: Config,
...databases: Array<string>
): Promise<void>;
}
@@ -17,3 +17,4 @@
export * from './connection';
export * from './types';
export * from './SingleConnection';
export * from './PluginConnection';
@@ -18,6 +18,7 @@ import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import knexFactory, { Knex } from 'knex';
import { mergeDatabaseConfig } from './config';
import { DatabaseConnector } from './connector';
import yn from 'yn';
/**
@@ -159,3 +160,23 @@ export async function ensureMysqlDatabaseExists(
await admin.destroy();
}
}
export function createMysqlNameOverride(name: string): Partial<Knex.Config> {
return {
connection: {
database: name,
},
};
}
/**
* MySql database connector.
*
* Exposes database connector functionality via an immutable object.
* */
export const mysqlConnector: DatabaseConnector = Object.freeze({
createClient: createMysqlDatabaseClient,
ensureDatabaseExists: ensureMysqlDatabaseExists,
createNameOverride: createMysqlNameOverride,
parseConnectionString: parseMysqlConnectionString,
});
@@ -17,6 +17,7 @@
import knexFactory, { Knex } from 'knex';
import { Config } from '@backstage/config';
import { mergeDatabaseConfig } from './config';
import { DatabaseConnector } from './connector';
/**
* Creates a knex postgres database connection
@@ -131,3 +132,23 @@ export async function ensurePgDatabaseExists(
await admin.destroy();
}
}
export function createPgNameOverride(name: string): Partial<Knex.Config> {
return {
connection: {
database: name,
},
};
}
/**
* PostgreSQL database connector.
*
* Exposes database connector functionality via an immutable object.
* */
export const pgConnector: DatabaseConnector = Object.freeze({
createClient: createPgDatabaseClient,
ensureDatabaseExists: ensurePgDatabaseExists,
createNameOverride: createPgNameOverride,
parseConnectionString: parsePgConnectionString,
});
@@ -19,6 +19,7 @@ import { ensureDirSync } from 'fs-extra';
import knexFactory, { Knex } from 'knex';
import path from 'path';
import { mergeDatabaseConfig } from './config';
import { DatabaseConnector } from './connector';
/**
* Creates a knex sqlite3 database connection
@@ -99,3 +100,28 @@ export function buildSqliteDatabaseConfig(
return config;
}
export function createSqliteNameOverride(name: string): Partial<Knex.Config> {
return {
connection: parseSqliteConnectionString(name),
};
}
export function parseSqliteConnectionString(
name: string,
): Knex.Sqlite3ConnectionConfig {
return {
filename: name,
};
}
/**
* Sqlite3 database connector.
*
* Exposes database connector functionality via an immutable object.
* */
export const sqlite3Connector: DatabaseConnector = Object.freeze({
createClient: createSqliteDatabaseClient,
createNameOverride: createSqliteNameOverride,
parseConnectionString: parseSqliteConnectionString,
});
+2 -2
View File
@@ -29,7 +29,7 @@ import {
getRootLogger,
loadBackendConfig,
notFoundHandler,
SingleConnectionDatabaseManager,
PluginConnectionDatabaseManager,
SingleHostDiscovery,
UrlReaders,
useHotMemoize,
@@ -59,7 +59,7 @@ function makeCreateEnv(config: Config) {
root.info(`Created UrlReader ${reader}`);
const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
const databaseManager = PluginConnectionDatabaseManager.fromConfig(config);
const cacheManager = CacheManager.fromConfig(config);
return (plugin: string): PluginEnvironment => {