From c9468995faea1a71c3b78f3e15ccfcb230b7e697 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 30 Apr 2026 13:07:04 +0200 Subject: [PATCH] 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 Made-with: Cursor --- .../src/lib/runner/runBackend.test.ts | 189 ++---------------- .../src/lib/runner/runBackend.ts | 85 +------- .../src/lib/runner/startEmbeddedDb.ts | 110 +++++++++- 3 files changed, 131 insertions(+), 253 deletions(-) diff --git a/packages/cli-module-build/src/lib/runner/runBackend.test.ts b/packages/cli-module-build/src/lib/runner/runBackend.test.ts index 47211304f0..93306c589f 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.test.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.test.ts @@ -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; - const mockToConfig = ConfigSources.toConfig as jest.MockedFunction< - typeof ConfigSources.toConfig - >; - - function mockConfig( - overrides?: Parameters[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 = { - '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 = { - '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 diff --git a/packages/cli-module-build/src/lib/runner/runBackend.ts b/packages/cli-module-build/src/lib/runner/runBackend.ts index 9e872fed38..15c228cb95 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -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 = {}; - let embeddedDb: Awaited> | undefined; - - const dbConfig = await readDatabaseConfig( - options.configPaths, - options.targetDir, - ); - if (dbConfig?.client === 'embedded-postgres') { - embeddedDb = await startEmbeddedDb(dbConfig.connection); - const overrides: Record = { - 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(); - } -} diff --git a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts index 7c8da74e00..f04a917721 100644 --- a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts +++ b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts @@ -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; + close(): Promise; + } + | 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 = { + client: 'pg', + }; const defaultedConnection: Record = {}; 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(); + } +}