Move config reading into startEmbeddedDb
Encapsulate the config-loading and embedded-postgres decision inside `startEmbeddedDb` so that `runBackend` only needs to pass through the raw `configPaths` and `targetDir`. This simplifies the interface between the two modules and keeps all embedded-postgres concerns in one place. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { ConfigSources } from '@backstage/config-loader';
|
||||
import { runBackend } from './runBackend';
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
@@ -51,15 +49,6 @@ jest.mock('ctrlc-windows', () => ({
|
||||
ctrlc: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockConfigSourcesDefault = jest.fn().mockReturnValue({});
|
||||
|
||||
jest.mock('@backstage/config-loader', () => ({
|
||||
ConfigSources: {
|
||||
default: (...args: any[]) => mockConfigSourcesDefault(...args),
|
||||
toConfig: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockStartEmbeddedDb = jest.fn();
|
||||
|
||||
jest.mock('./startEmbeddedDb', () => ({
|
||||
@@ -70,17 +59,6 @@ describe('runBackend', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let originalPlatform: string;
|
||||
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||
const mockToConfig = ConfigSources.toConfig as jest.MockedFunction<
|
||||
typeof ConfigSources.toConfig
|
||||
>;
|
||||
|
||||
function mockConfig(
|
||||
overrides?: Parameters<typeof mockServices.rootConfig.mock>[0],
|
||||
) {
|
||||
const config = mockServices.rootConfig.mock(overrides);
|
||||
mockToConfig.mockResolvedValue(Object.assign(config, { close: jest.fn() }));
|
||||
return config;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Use fake timers to control debounce
|
||||
@@ -97,8 +75,8 @@ describe('runBackend', () => {
|
||||
// Mock process.once to prevent actual signal handling
|
||||
jest.spyOn(process, 'once').mockReturnValue(process);
|
||||
|
||||
mockConfig();
|
||||
mockStartEmbeddedDb.mockReset();
|
||||
mockStartEmbeddedDb.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -189,23 +167,16 @@ describe('runBackend', () => {
|
||||
});
|
||||
|
||||
describe('embedded-postgres support', () => {
|
||||
it('should start embedded DB and inject all defaults when no user connection config is provided', async () => {
|
||||
mockConfig({
|
||||
getOptionalString: (key: string) =>
|
||||
key === 'backend.database.client' ? 'embedded-postgres' : undefined,
|
||||
});
|
||||
it('should inject config override from startEmbeddedDb when it returns a result', async () => {
|
||||
mockStartEmbeddedDb.mockResolvedValue({
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
port: 5555,
|
||||
},
|
||||
defaultedConnection: {
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
port: 5555,
|
||||
configOverride: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
port: 5555,
|
||||
},
|
||||
},
|
||||
close: jest.fn(),
|
||||
});
|
||||
@@ -213,7 +184,10 @@ describe('runBackend', () => {
|
||||
runBackend({ entry: 'src/index' });
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockStartEmbeddedDb).toHaveBeenCalledWith(undefined);
|
||||
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
|
||||
configPaths: undefined,
|
||||
targetDir: undefined,
|
||||
});
|
||||
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
|
||||
string,
|
||||
string
|
||||
@@ -230,119 +204,7 @@ describe('runBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward user-provided connection config and only inject defaults for missing values', async () => {
|
||||
const configValues: Record<string, string | number> = {
|
||||
'backend.database.client': 'embedded-postgres',
|
||||
'backend.database.connection.host': '0.0.0.0',
|
||||
'backend.database.connection.port': 15432,
|
||||
};
|
||||
mockConfig({
|
||||
getOptional: ((key: string) =>
|
||||
key === 'backend.database.connection'
|
||||
? { host: '0.0.0.0', port: 15432 }
|
||||
: undefined) as any,
|
||||
getOptionalString: (key: string) => {
|
||||
const val = configValues[key];
|
||||
return typeof val === 'string' ? val : undefined;
|
||||
},
|
||||
getOptionalNumber: (key: string) => {
|
||||
const val = configValues[key];
|
||||
return typeof val === 'number' ? val : undefined;
|
||||
},
|
||||
});
|
||||
mockStartEmbeddedDb.mockResolvedValue({
|
||||
connection: {
|
||||
host: '0.0.0.0',
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
port: 15432,
|
||||
},
|
||||
defaultedConnection: {
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
},
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
runBackend({ entry: 'src/index' });
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
|
||||
host: '0.0.0.0',
|
||||
port: 15432,
|
||||
user: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const injected = JSON.parse(spawnEnv.APP_CONFIG_backend_database);
|
||||
expect(injected).toEqual({
|
||||
client: 'pg',
|
||||
connection: {
|
||||
user: 'postgres',
|
||||
password: 'password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not inject connection overrides when all values are user-provided', async () => {
|
||||
const configValues: Record<string, string | number> = {
|
||||
'backend.database.client': 'embedded-postgres',
|
||||
'backend.database.connection.host': '0.0.0.0',
|
||||
'backend.database.connection.port': 15432,
|
||||
'backend.database.connection.user': 'myuser',
|
||||
'backend.database.connection.password': 'mypass',
|
||||
};
|
||||
mockConfig({
|
||||
getOptional: ((key: string) =>
|
||||
key === 'backend.database.connection'
|
||||
? {
|
||||
host: '0.0.0.0',
|
||||
port: 15432,
|
||||
user: 'myuser',
|
||||
password: 'mypass',
|
||||
}
|
||||
: undefined) as any,
|
||||
getOptionalString: (key: string) => {
|
||||
const val = configValues[key];
|
||||
return typeof val === 'string' ? val : undefined;
|
||||
},
|
||||
getOptionalNumber: (key: string) => {
|
||||
const val = configValues[key];
|
||||
return typeof val === 'number' ? val : undefined;
|
||||
},
|
||||
});
|
||||
mockStartEmbeddedDb.mockResolvedValue({
|
||||
connection: {
|
||||
host: '0.0.0.0',
|
||||
user: 'myuser',
|
||||
password: 'mypass',
|
||||
port: 15432,
|
||||
},
|
||||
defaultedConnection: {},
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
runBackend({ entry: 'src/index' });
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
|
||||
host: '0.0.0.0',
|
||||
port: 15432,
|
||||
user: 'myuser',
|
||||
password: 'mypass',
|
||||
});
|
||||
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const injected = JSON.parse(spawnEnv.APP_CONFIG_backend_database);
|
||||
expect(injected).toEqual({ client: 'pg' });
|
||||
});
|
||||
|
||||
it('should resolve config paths relative to targetDir', async () => {
|
||||
it('should forward configPaths and targetDir to startEmbeddedDb', async () => {
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
targetDir: '/root/packages/backend',
|
||||
@@ -350,27 +212,18 @@ describe('runBackend', () => {
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockConfigSourcesDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
argv: ['--config', '/root/config/local.yaml'],
|
||||
}),
|
||||
);
|
||||
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
|
||||
configPaths: ['../../config/local.yaml'],
|
||||
targetDir: '/root/packages/backend',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not start embedded DB for other database clients with a string connection', async () => {
|
||||
mockConfig({
|
||||
getOptional: ((key: string) =>
|
||||
key === 'backend.database.connection'
|
||||
? ':memory:'
|
||||
: undefined) as any,
|
||||
getOptionalString: (key: string) =>
|
||||
key === 'backend.database.client' ? 'better-sqlite3' : undefined,
|
||||
});
|
||||
it('should not inject config override when startEmbeddedDb returns undefined', async () => {
|
||||
mockStartEmbeddedDb.mockResolvedValue(undefined);
|
||||
|
||||
runBackend({ entry: 'src/index' });
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockStartEmbeddedDb).not.toHaveBeenCalled();
|
||||
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
|
||||
string,
|
||||
string
|
||||
|
||||
@@ -20,12 +20,8 @@ import { ctrlc } from 'ctrlc-windows';
|
||||
import { IpcServer, ServerDataStore } from '../ipc';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
isAbsolute as isAbsolutePath,
|
||||
resolve as resolvePath,
|
||||
} from 'node:path';
|
||||
import { isAbsolute as isAbsolutePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import { ConfigSources } from '@backstage/config-loader';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
import { startEmbeddedDb } from './startEmbeddedDb';
|
||||
@@ -66,21 +62,14 @@ export async function runBackend(options: RunBackendOptions) {
|
||||
|
||||
const extraEnv: Record<string, string> = {};
|
||||
|
||||
let embeddedDb: Awaited<ReturnType<typeof startEmbeddedDb>> | undefined;
|
||||
|
||||
const dbConfig = await readDatabaseConfig(
|
||||
options.configPaths,
|
||||
options.targetDir,
|
||||
);
|
||||
if (dbConfig?.client === 'embedded-postgres') {
|
||||
embeddedDb = await startEmbeddedDb(dbConfig.connection);
|
||||
const overrides: Record<string, unknown> = {
|
||||
client: 'pg',
|
||||
};
|
||||
if (Object.keys(embeddedDb.defaultedConnection).length > 0) {
|
||||
overrides.connection = embeddedDb.defaultedConnection;
|
||||
}
|
||||
extraEnv.APP_CONFIG_backend_database = JSON.stringify(overrides);
|
||||
const embeddedDb = await startEmbeddedDb({
|
||||
configPaths: options.configPaths,
|
||||
targetDir: options.targetDir,
|
||||
});
|
||||
if (embeddedDb) {
|
||||
extraEnv.APP_CONFIG_backend_database = JSON.stringify(
|
||||
embeddedDb.configOverride,
|
||||
);
|
||||
}
|
||||
|
||||
let exiting = false;
|
||||
@@ -223,59 +212,3 @@ export async function runBackend(options: RunBackendOptions) {
|
||||
|
||||
return () => exitPromise;
|
||||
}
|
||||
|
||||
async function readDatabaseConfig(
|
||||
configPaths?: string[],
|
||||
targetDir?: string,
|
||||
): Promise<
|
||||
| {
|
||||
client: string;
|
||||
connection?: import('./startEmbeddedDb').EmbeddedDbConnectionConfig;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const rootDir = targetPaths.rootDir;
|
||||
const configBaseDir = targetDir ?? rootDir;
|
||||
const source = ConfigSources.default({
|
||||
rootDir,
|
||||
allowMissingDefaultConfig: true,
|
||||
argv: (configPaths ?? []).flatMap(p => [
|
||||
'--config',
|
||||
isAbsolutePath(p) ? p : resolvePath(configBaseDir, p),
|
||||
]),
|
||||
});
|
||||
|
||||
const config = await ConfigSources.toConfig(source);
|
||||
try {
|
||||
const client = config.getOptionalString('backend.database.client');
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Only read structured connection config if the value is an object;
|
||||
// it can also be a plain string (e.g. ':memory:' for better-sqlite3).
|
||||
const rawConnection = config.getOptional('backend.database.connection');
|
||||
if (typeof rawConnection === 'string' || rawConnection === undefined) {
|
||||
return { client };
|
||||
}
|
||||
|
||||
const host = config.getOptionalString('backend.database.connection.host');
|
||||
const port = config.getOptionalNumber('backend.database.connection.port');
|
||||
const user = config.getOptionalString('backend.database.connection.user');
|
||||
const password = config.getOptionalString(
|
||||
'backend.database.connection.password',
|
||||
);
|
||||
|
||||
const connection =
|
||||
host !== undefined ||
|
||||
port !== undefined ||
|
||||
user !== undefined ||
|
||||
password !== undefined
|
||||
? { host, port, user, password }
|
||||
: undefined;
|
||||
|
||||
return { client, connection };
|
||||
} finally {
|
||||
config.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
|
||||
import os from 'node:os';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
isAbsolute as isAbsolutePath,
|
||||
resolve as resolvePath,
|
||||
} from 'node:path';
|
||||
import { getPortPromise } from 'portfinder';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { ConfigSources } from '@backstage/config-loader';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const TEMP_DIR_PREFIX = 'backstage-dev-db-';
|
||||
@@ -59,7 +64,38 @@ export interface EmbeddedDbConnectionConfig {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
|
||||
export interface StartEmbeddedDbOptions {
|
||||
configPaths?: string[];
|
||||
targetDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the database configuration from the app config and, if the client is
|
||||
* `embedded-postgres`, starts an embedded Postgres instance. Returns the config
|
||||
* override to inject into the child process, or `undefined` if the database
|
||||
* client is not `embedded-postgres`.
|
||||
*/
|
||||
export async function startEmbeddedDb(options: StartEmbeddedDbOptions): Promise<
|
||||
| {
|
||||
configOverride: Record<string, unknown>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const dbConfig = await readDatabaseConfig(
|
||||
options.configPaths,
|
||||
options.targetDir,
|
||||
);
|
||||
if (dbConfig?.client !== 'embedded-postgres') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return startEmbeddedDbInternal(dbConfig.connection);
|
||||
}
|
||||
|
||||
async function startEmbeddedDbInternal(
|
||||
userConfig?: EmbeddedDbConnectionConfig,
|
||||
) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
'WARNING: Using embedded-postgres for local development is experimental and subject to change',
|
||||
@@ -107,6 +143,9 @@ export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const configOverride: Record<string, unknown> = {
|
||||
client: 'pg',
|
||||
};
|
||||
const defaultedConnection: Record<string, string | number> = {};
|
||||
if (!userConfig?.host) {
|
||||
defaultedConnection.host = host;
|
||||
@@ -120,18 +159,71 @@ export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
|
||||
if (!userConfig?.port) {
|
||||
defaultedConnection.port = port;
|
||||
}
|
||||
if (Object.keys(defaultedConnection).length > 0) {
|
||||
configOverride.connection = defaultedConnection;
|
||||
}
|
||||
|
||||
return {
|
||||
connection: {
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
},
|
||||
defaultedConnection,
|
||||
configOverride,
|
||||
async close() {
|
||||
await pg.stop();
|
||||
await fs.remove(tmpDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readDatabaseConfig(
|
||||
configPaths?: string[],
|
||||
targetDir?: string,
|
||||
): Promise<
|
||||
| {
|
||||
client: string;
|
||||
connection?: EmbeddedDbConnectionConfig;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const rootDir = targetPaths.rootDir;
|
||||
const configBaseDir = targetDir ?? rootDir;
|
||||
const source = ConfigSources.default({
|
||||
rootDir,
|
||||
allowMissingDefaultConfig: true,
|
||||
argv: (configPaths ?? []).flatMap(p => [
|
||||
'--config',
|
||||
isAbsolutePath(p) ? p : resolvePath(configBaseDir, p),
|
||||
]),
|
||||
});
|
||||
|
||||
const config = await ConfigSources.toConfig(source);
|
||||
try {
|
||||
const client = config.getOptionalString('backend.database.client');
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Only read structured connection config if the value is an object;
|
||||
// it can also be a plain string (e.g. ':memory:' for better-sqlite3).
|
||||
const rawConnection = config.getOptional('backend.database.connection');
|
||||
if (typeof rawConnection === 'string' || rawConnection === undefined) {
|
||||
return { client };
|
||||
}
|
||||
|
||||
const host = config.getOptionalString('backend.database.connection.host');
|
||||
const port = config.getOptionalNumber('backend.database.connection.port');
|
||||
const user = config.getOptionalString('backend.database.connection.user');
|
||||
const password = config.getOptionalString(
|
||||
'backend.database.connection.password',
|
||||
);
|
||||
|
||||
const connection =
|
||||
host !== undefined ||
|
||||
port !== undefined ||
|
||||
user !== undefined ||
|
||||
password !== undefined
|
||||
? { host, port, user, password }
|
||||
: undefined;
|
||||
|
||||
return { client, connection };
|
||||
} finally {
|
||||
config.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user