From be7e4eb48b3aa797ff618669aee81bdbb2c6b87a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 30 Apr 2026 11:34:51 +0200 Subject: [PATCH 1/5] cli-module-build: forward user config to embedded Postgres When using the embedded-postgres database client, user-provided connection config (host, port, user, password) is now forwarded to the embedded Postgres instance. Only values that the user hasn't configured are filled in with defaults and injected into the app config, preserving existing behavior when no config is provided. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../forward-embedded-postgres-config.md | 5 + .../src/lib/runner/runBackend.test.ts | 117 +++++++++++++++++- .../src/lib/runner/runBackend.ts | 47 +++++-- .../src/lib/runner/startEmbeddedDb.ts | 32 ++++- 4 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 .changeset/forward-embedded-postgres-config.md diff --git a/.changeset/forward-embedded-postgres-config.md b/.changeset/forward-embedded-postgres-config.md new file mode 100644 index 0000000000..cfae71e88c --- /dev/null +++ b/.changeset/forward-embedded-postgres-config.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-build': patch +--- + +The embedded Postgres database used during local development now respects user-provided connection configuration. If you configure `host`, `port`, `user`, or `password` under `backend.database.connection` alongside the `embedded-postgres` database client, those values will be forwarded to the embedded Postgres instance. Only values that you have not configured will be filled in with defaults. This makes it possible to run the embedded database on a specific host and port, for example to connect to it externally with `psql`. 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 4f655b041c..1bf2ef1568 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.test.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.test.ts @@ -88,6 +88,7 @@ describe('runBackend', () => { mockToConfig.mockResolvedValue({ close: jest.fn(), getOptionalString: () => undefined, + getOptionalNumber: () => undefined, }); mockStartEmbeddedDb.mockReset(); }); @@ -180,11 +181,12 @@ describe('runBackend', () => { }); describe('embedded-postgres support', () => { - it('should start embedded DB and inject config when database client is embedded-postgres', async () => { + it('should start embedded DB and inject all defaults when no user connection config is provided', async () => { mockToConfig.mockResolvedValue({ close: jest.fn(), getOptionalString: (key: string) => key === 'backend.database.client' ? 'embedded-postgres' : undefined, + getOptionalNumber: () => undefined, }); mockStartEmbeddedDb.mockResolvedValue({ connection: { @@ -193,14 +195,19 @@ describe('runBackend', () => { password: 'password', port: 5555, }, + defaultedConnection: { + host: 'localhost', + user: 'postgres', + password: 'password', + port: 5555, + }, close: jest.fn(), }); runBackend({ entry: 'src/index' }); await jest.advanceTimersByTimeAsync(100); - expect(mockStartEmbeddedDb).toHaveBeenCalled(); - expect(mockSpawn).toHaveBeenCalled(); + expect(mockStartEmbeddedDb).toHaveBeenCalledWith(undefined); const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record< string, string @@ -217,10 +224,112 @@ 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, + }; + mockToConfig.mockResolvedValue({ + close: jest.fn(), + 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', + }; + mockToConfig.mockResolvedValue({ + close: jest.fn(), + 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 () => { mockToConfig.mockResolvedValue({ close: jest.fn(), getOptionalString: () => undefined, + getOptionalNumber: () => undefined, }); runBackend({ @@ -242,13 +351,13 @@ describe('runBackend', () => { close: jest.fn(), getOptionalString: (key: string) => key === 'backend.database.client' ? 'better-sqlite3' : undefined, + getOptionalNumber: () => undefined, }); runBackend({ entry: 'src/index' }); await jest.advanceTimersByTimeAsync(100); expect(mockStartEmbeddedDb).not.toHaveBeenCalled(); - expect(mockSpawn).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 95dd8784fd..1046235d35 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -68,16 +68,19 @@ export async function runBackend(options: RunBackendOptions) { let embeddedDb: Awaited> | undefined; - const dbClient = await readDatabaseClient( + const dbConfig = await readDatabaseConfig( options.configPaths, options.targetDir, ); - if (dbClient === 'embedded-postgres') { - embeddedDb = await startEmbeddedDb(); - extraEnv.APP_CONFIG_backend_database = JSON.stringify({ + if (dbConfig?.client === 'embedded-postgres') { + embeddedDb = await startEmbeddedDb(dbConfig.connection); + const overrides: Record = { client: 'pg', - connection: embeddedDb.connection, - }); + }; + if (Object.keys(embeddedDb.defaultedConnection).length > 0) { + overrides.connection = embeddedDb.defaultedConnection; + } + extraEnv.APP_CONFIG_backend_database = JSON.stringify(overrides); } let exiting = false; @@ -221,10 +224,16 @@ export async function runBackend(options: RunBackendOptions) { return () => exitPromise; } -async function readDatabaseClient( +async function readDatabaseConfig( configPaths?: string[], targetDir?: string, -): Promise { +): Promise< + | { + client: string; + connection?: import('./startEmbeddedDb').EmbeddedDbConnectionConfig; + } + | undefined +> { const rootDir = targetPaths.rootDir; const configBaseDir = targetDir ?? rootDir; const source = ConfigSources.default({ @@ -238,7 +247,27 @@ async function readDatabaseClient( const config = await ConfigSources.toConfig(source); try { - return config.getOptionalString('backend.database.client'); + const client = config.getOptionalString('backend.database.client'); + if (!client) { + return undefined; + } + + 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 334f76878e..7c8da74e00 100644 --- a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts +++ b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts @@ -52,7 +52,14 @@ async function cleanStaleDatabases() { ); } -export async function startEmbeddedDb() { +export interface EmbeddedDbConnectionConfig { + host?: string; + port?: number; + user?: string; + password?: string; +} + +export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) { console.warn( chalk.yellow( 'WARNING: Using embedded-postgres for local development is experimental and subject to change', @@ -72,10 +79,10 @@ export async function startEmbeddedDb() { await cleanStaleDatabases(); - const host = 'localhost'; - const user = 'postgres'; - const password = 'password'; - const port = await getPortPromise(); + const host = userConfig?.host ?? 'localhost'; + const user = userConfig?.user ?? 'postgres'; + const password = userConfig?.password ?? 'password'; + const port = userConfig?.port ?? (await getPortPromise()); const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), TEMP_DIR_PREFIX)); const pg = new EmbeddedPostgres({ @@ -100,6 +107,20 @@ export async function startEmbeddedDb() { throw error; } + const defaultedConnection: Record = {}; + if (!userConfig?.host) { + defaultedConnection.host = host; + } + if (!userConfig?.user) { + defaultedConnection.user = user; + } + if (!userConfig?.password) { + defaultedConnection.password = password; + } + if (!userConfig?.port) { + defaultedConnection.port = port; + } + return { connection: { host, @@ -107,6 +128,7 @@ export async function startEmbeddedDb() { password, port, }, + defaultedConnection, async close() { await pg.stop(); await fs.remove(tmpDir); From d7e22c23c4b7fa75a039f94dc79466a5742336cd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 30 Apr 2026 11:50:58 +0200 Subject: [PATCH 2/5] Fix config read crash when database connection is a string The readDatabaseConfig function would crash when trying to read sub-keys of backend.database.connection when the value is a plain string (e.g. ':memory:' for better-sqlite3). Guard by checking the raw value type before attempting to read structured connection keys. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/lib/runner/runBackend.test.ts | 13 ++++++++++++- .../cli-module-build/src/lib/runner/runBackend.ts | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 1bf2ef1568..0cf2b6bd4c 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.test.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.test.ts @@ -87,6 +87,7 @@ describe('runBackend', () => { mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => undefined, getOptionalString: () => undefined, getOptionalNumber: () => undefined, }); @@ -184,6 +185,7 @@ describe('runBackend', () => { it('should start embedded DB and inject all defaults when no user connection config is provided', async () => { mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => undefined, getOptionalString: (key: string) => key === 'backend.database.client' ? 'embedded-postgres' : undefined, getOptionalNumber: () => undefined, @@ -232,6 +234,7 @@ describe('runBackend', () => { }; mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => ({ host: '0.0.0.0', port: 15432 }), getOptionalString: (key: string) => { const val = configValues[key]; return typeof val === 'string' ? val : undefined; @@ -288,6 +291,12 @@ describe('runBackend', () => { }; mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => ({ + host: '0.0.0.0', + port: 15432, + user: 'myuser', + password: 'mypass', + }), getOptionalString: (key: string) => { const val = configValues[key]; return typeof val === 'string' ? val : undefined; @@ -328,6 +337,7 @@ describe('runBackend', () => { it('should resolve config paths relative to targetDir', async () => { mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => undefined, getOptionalString: () => undefined, getOptionalNumber: () => undefined, }); @@ -346,9 +356,10 @@ describe('runBackend', () => { ); }); - it('should not start embedded DB for other database clients', async () => { + it('should not start embedded DB for other database clients with a string connection', async () => { mockToConfig.mockResolvedValue({ close: jest.fn(), + getOptional: () => ':memory:', getOptionalString: (key: string) => key === 'backend.database.client' ? 'better-sqlite3' : undefined, getOptionalNumber: () => undefined, diff --git a/packages/cli-module-build/src/lib/runner/runBackend.ts b/packages/cli-module-build/src/lib/runner/runBackend.ts index 1046235d35..9e872fed38 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -252,6 +252,13 @@ async function readDatabaseConfig( 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'); From a3a1802284b5dc17315a39c22c6720b6b25a6d4f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 30 Apr 2026 12:35:55 +0200 Subject: [PATCH 3/5] Use mockServices.rootConfig.mock for config mocking in tests Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/lib/runner/runBackend.test.ts | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 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 0cf2b6bd4c..47211304f0 100644 --- a/packages/cli-module-build/src/lib/runner/runBackend.test.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.test.ts @@ -14,6 +14,8 @@ * 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'; @@ -49,13 +51,12 @@ jest.mock('ctrlc-windows', () => ({ ctrlc: jest.fn(), })); -const mockToConfig = jest.fn(); const mockConfigSourcesDefault = jest.fn().mockReturnValue({}); jest.mock('@backstage/config-loader', () => ({ ConfigSources: { default: (...args: any[]) => mockConfigSourcesDefault(...args), - toConfig: (...args: any[]) => mockToConfig(...args), + toConfig: jest.fn(), }, })); @@ -69,6 +70,17 @@ 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 @@ -85,12 +97,7 @@ describe('runBackend', () => { // Mock process.once to prevent actual signal handling jest.spyOn(process, 'once').mockReturnValue(process); - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => undefined, - getOptionalString: () => undefined, - getOptionalNumber: () => undefined, - }); + mockConfig(); mockStartEmbeddedDb.mockReset(); }); @@ -183,12 +190,9 @@ describe('runBackend', () => { describe('embedded-postgres support', () => { it('should start embedded DB and inject all defaults when no user connection config is provided', async () => { - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => undefined, + mockConfig({ getOptionalString: (key: string) => key === 'backend.database.client' ? 'embedded-postgres' : undefined, - getOptionalNumber: () => undefined, }); mockStartEmbeddedDb.mockResolvedValue({ connection: { @@ -232,9 +236,11 @@ describe('runBackend', () => { 'backend.database.connection.host': '0.0.0.0', 'backend.database.connection.port': 15432, }; - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => ({ host: '0.0.0.0', 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; @@ -289,14 +295,16 @@ describe('runBackend', () => { 'backend.database.connection.user': 'myuser', 'backend.database.connection.password': 'mypass', }; - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => ({ - host: '0.0.0.0', - port: 15432, - user: 'myuser', - 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; @@ -335,13 +343,6 @@ describe('runBackend', () => { }); it('should resolve config paths relative to targetDir', async () => { - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => undefined, - getOptionalString: () => undefined, - getOptionalNumber: () => undefined, - }); - runBackend({ entry: 'src/index', targetDir: '/root/packages/backend', @@ -357,12 +358,13 @@ describe('runBackend', () => { }); it('should not start embedded DB for other database clients with a string connection', async () => { - mockToConfig.mockResolvedValue({ - close: jest.fn(), - getOptional: () => ':memory:', + mockConfig({ + getOptional: ((key: string) => + key === 'backend.database.connection' + ? ':memory:' + : undefined) as any, getOptionalString: (key: string) => key === 'backend.database.client' ? 'better-sqlite3' : undefined, - getOptionalNumber: () => undefined, }); runBackend({ entry: 'src/index' }); From c9468995faea1a71c3b78f3e15ccfcb230b7e697 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 30 Apr 2026 13:07:04 +0200 Subject: [PATCH 4/5] 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(); + } +} From 2a03b0798cf1bf47167513c89f4933ca00e58a56 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 19 May 2026 11:48:20 +0200 Subject: [PATCH 5/5] Apply suggestion from @Rugvip Signed-off-by: Patrik Oldsberg --- packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts index f04a917721..e0d30bcd50 100644 --- a/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts +++ b/packages/cli-module-build/src/lib/runner/startEmbeddedDb.ts @@ -117,7 +117,7 @@ async function startEmbeddedDbInternal( const host = userConfig?.host ?? 'localhost'; const user = userConfig?.user ?? 'postgres'; - const password = userConfig?.password ?? 'password'; + const password = userConfig?.password ?? 'postgres'; const port = userConfig?.port ?? (await getPortPromise()); const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), TEMP_DIR_PREFIX));