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
+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,
});