From 3ba6be2a26263fd9d2eb1320a223365556d667aa Mon Sep 17 00:00:00 2001 From: AYANscyy2 Date: Mon, 28 Sep 2026 18:17:47 +0530 Subject: [PATCH 1/2] fix(sim-setup): report Compose db/redis state in status --- packages/sim-setup/src/lifecycle.test.ts | 18 +++++++- packages/sim-setup/src/lifecycle.ts | 57 +++++++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/sim-setup/src/lifecycle.test.ts b/packages/sim-setup/src/lifecycle.test.ts index d53ce9c31a3..c2c6ff3956d 100644 --- a/packages/sim-setup/src/lifecycle.test.ts +++ b/packages/sim-setup/src/lifecycle.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' import { ensureProductionComposeFile } from './compose-asset' -import { composeInstallFromDirectory } from './lifecycle' +import { composeInstallFromDirectory, composeServiceState } from './lifecycle' describe('setup lifecycle', () => { it('does not duplicate a running install restored from its directory', () => { @@ -17,4 +17,20 @@ describe('setup lifecycle', () => { rmSync(root, { recursive: true, force: true }) } }) + + it('reads a Compose service as running when any replica is running', () => { + expect(composeServiceState('running')).toEqual({ state: 'running' }) + expect(composeServiceState('exited\nrunning\n')).toEqual({ state: 'running' }) + }) + + it('reads a Compose service with only stopped containers as stopped', () => { + expect(composeServiceState('exited')).toEqual({ state: 'stopped' }) + expect(composeServiceState('created\nexited')).toEqual({ state: 'stopped' }) + }) + + it('reads a missing Compose service or failed probe as absent', () => { + expect(composeServiceState('')).toBeNull() + expect(composeServiceState('\n \n')).toBeNull() + expect(composeServiceState(null)).toBeNull() + }) }) diff --git a/packages/sim-setup/src/lifecycle.ts b/packages/sim-setup/src/lifecycle.ts index ddf3047b7c1..7ff47267b3a 100644 --- a/packages/sim-setup/src/lifecycle.ts +++ b/packages/sim-setup/src/lifecycle.ts @@ -61,6 +61,45 @@ function dockerText(args: string[], cwd: string = ROOT): string | null { return result.status === 0 ? result.stdout.trim() : null } +/** + * Reduces `docker ps --format '{{.State}}'` output for one Compose service to the + * same shape `runDetection` uses for standalone containers. A scaled service can + * have several replicas; any running replica counts as running. + */ +export function composeServiceState( + stateOutput: string | null +): { state: 'running' | 'stopped' } | null { + const states = (stateOutput ?? '') + .split('\n') + .map((state) => state.trim()) + .filter(Boolean) + if (states.length === 0) return null + return { state: states.includes('running') ? 'running' : 'stopped' } +} + +/** + * Compose names containers `--`, so the standalone + * `sim-postgres` / `sim-redis` name lookup never sees them. Find the service by + * the labels Compose stamps on every container instead. + */ +function composeServiceContainer( + install: ComposeInstall, + service: 'db' | 'redis' +): { state: 'running' | 'stopped' } | null { + return composeServiceState( + dockerText([ + 'ps', + '-a', + '--filter', + `label=com.docker.compose.project=${install.project}`, + '--filter', + `label=com.docker.compose.service=${service}`, + '--format', + '{{.State}}', + ]) + ) +} + /** Docker command whose output the user should see (up, logs); returns exit code. */ function dockerInherit(args: string[], cwd: string = ROOT): number { return spawnSync('docker', args, { cwd, stdio: 'inherit' }).status ?? 1 @@ -591,8 +630,22 @@ async function status(): Promise { const containerState = (state: { state: 'running' | 'stopped' } | null) => docker ? (state ? state.state : 'absent') : 'unknown (docker down)' console.log() - console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`) - console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`) + const composeStacks = installs.filter((install) => install.kind === 'compose') + // Standalone containers belong to the dev install; Compose stacks run their own + // `db` / `redis` services, which the standalone name lookup can't see. + if (composeStacks.length === 0 || installs.some((install) => install.kind === 'dev')) { + console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`) + console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`) + } + for (const stack of composeStacks) { + const scope = composeStacks.length > 1 ? `${stack.project} ` : '' + console.log( + ` ${scope}postgres (compose db): ${containerState(composeServiceContainer(stack, 'db'))}` + ) + console.log( + ` ${scope}redis (compose redis): ${containerState(composeServiceContainer(stack, 'redis'))}` + ) + } const [app, realtime] = await Promise.all([ httpHealth(`${APP_URL}/api/health`), httpHealth(REALTIME_HEALTH), From 5cc565949734e80a6875a95575d1b7de1adc13ab Mon Sep 17 00:00:00 2001 From: AYANscyy2 Date: Mon, 28 Sep 2026 21:35:28 +0530 Subject: [PATCH 2/2] fix(sim-setup): report failed Compose probes as unknown and test status rows --- packages/sim-setup/src/lifecycle.test.ts | 103 ++++++++++++++++++++- packages/sim-setup/src/lifecycle.ts | 111 +++++++++++++++-------- 2 files changed, 171 insertions(+), 43 deletions(-) diff --git a/packages/sim-setup/src/lifecycle.test.ts b/packages/sim-setup/src/lifecycle.test.ts index c2c6ff3956d..46e104a9b3f 100644 --- a/packages/sim-setup/src/lifecycle.test.ts +++ b/packages/sim-setup/src/lifecycle.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' import { ensureProductionComposeFile } from './compose-asset' -import { composeInstallFromDirectory, composeServiceState } from './lifecycle' +import { + composeInstallFromDirectory, + composeServiceQuery, + composeServiceState, + type Install, + serviceStatusRows, +} from './lifecycle' describe('setup lifecycle', () => { it('does not duplicate a running install restored from its directory', () => { @@ -28,9 +34,100 @@ describe('setup lifecycle', () => { expect(composeServiceState('created\nexited')).toEqual({ state: 'stopped' }) }) - it('reads a missing Compose service or failed probe as absent', () => { + it('reads an empty Compose query as absent and a failed one as unknown', () => { expect(composeServiceState('')).toBeNull() expect(composeServiceState('\n \n')).toBeNull() - expect(composeServiceState(null)).toBeNull() + expect(composeServiceState(null)).toBe('unknown') + }) +}) + +describe('status service rows', () => { + const compose = (project: string): Install => ({ + kind: 'compose', + file: `/srv/${project}/docker-compose.prod.yml`, + dir: `/srv/${project}`, + project, + }) + const dev: Install = { kind: 'dev', postgres: true, redis: true } + const standalone = { db: { state: 'running' as const }, redis: null } + + /** Answers `docker ps` by the project/service labels in the query, like Docker would. */ + const fakeDocker = + (states: Record) => + (args: string[]): string | null => { + const project = args.find((a) => a.startsWith('label=com.docker.compose.project=')) + const service = args.find((a) => a.startsWith('label=com.docker.compose.service=')) + const key = `${project?.split('=').pop()}/${service?.split('=').pop()}` + return key in states ? states[key]! : '' + } + + it('queries Compose services by project and service label', () => { + expect(composeServiceQuery('sim-abc', 'db')).toEqual([ + 'ps', + '-a', + '--filter', + 'label=com.docker.compose.project=sim-abc', + '--filter', + 'label=com.docker.compose.service=db', + '--format', + '{{.State}}', + ]) + }) + + it('shows only Compose rows for a Compose install', () => { + const rows = serviceStatusRows( + [compose('sim-abc')], + standalone, + fakeDocker({ 'sim-abc/db': 'running', 'sim-abc/redis': 'exited' }) + ) + expect(rows).toEqual([ + { label: 'postgres (compose db)', state: { state: 'running' } }, + { label: 'redis (compose redis)', state: { state: 'stopped' } }, + ]) + }) + + it('reports a failed Compose query as unknown, not absent', () => { + const rows = serviceStatusRows([compose('sim-abc')], standalone, () => null) + expect(rows.map((row) => row.state)).toEqual(['unknown', 'unknown']) + }) + + it('keeps standalone rows when a dev install is present', () => { + const rows = serviceStatusRows( + [dev, compose('sim-abc')], + standalone, + fakeDocker({ 'sim-abc/db': 'running', 'sim-abc/redis': 'running' }) + ) + expect(rows.map((row) => row.label)).toEqual([ + 'postgres (sim-postgres)', + 'redis (sim-redis)', + 'postgres (compose db)', + 'redis (compose redis)', + ]) + expect(rows[0]!.state).toEqual({ state: 'running' }) + expect(rows[1]!.state).toBeNull() + }) + + it('never queries Compose labels without a Compose install', () => { + let queried = false + const rows = serviceStatusRows([dev], standalone, () => { + queried = true + return '' + }) + expect(queried).toBe(false) + expect(rows.map((row) => row.label)).toEqual(['postgres (sim-postgres)', 'redis (sim-redis)']) + }) + + it('prefixes rows with the project when several Compose stacks run', () => { + const rows = serviceStatusRows( + [compose('sim-a'), compose('sim-b')], + standalone, + fakeDocker({ 'sim-a/db': 'running', 'sim-b/db': '' }) + ) + expect(rows.map((row) => [row.label, row.state])).toEqual([ + ['sim-a postgres (compose db)', { state: 'running' }], + ['sim-a redis (compose redis)', null], + ['sim-b postgres (compose db)', null], + ['sim-b redis (compose redis)', null], + ]) }) }) diff --git a/packages/sim-setup/src/lifecycle.ts b/packages/sim-setup/src/lifecycle.ts index 7ff47267b3a..53f781edc1f 100644 --- a/packages/sim-setup/src/lifecycle.ts +++ b/packages/sim-setup/src/lifecycle.ts @@ -61,15 +61,18 @@ function dockerText(args: string[], cwd: string = ROOT): string | null { return result.status === 0 ? result.stdout.trim() : null } +/** A service's container state, or `'unknown'` when the Docker query itself failed. */ +export type ServiceState = { state: 'running' | 'stopped' } | null | 'unknown' + /** * Reduces `docker ps --format '{{.State}}'` output for one Compose service to the * same shape `runDetection` uses for standalone containers. A scaled service can - * have several replicas; any running replica counts as running. + * have several replicas; any running replica counts as running. `null` means the + * query failed, which is not the same as "no containers", so it maps to `'unknown'`. */ -export function composeServiceState( - stateOutput: string | null -): { state: 'running' | 'stopped' } | null { - const states = (stateOutput ?? '') +export function composeServiceState(stateOutput: string | null): ServiceState { + if (stateOutput === null) return 'unknown' + const states = stateOutput .split('\n') .map((state) => state.trim()) .filter(Boolean) @@ -82,22 +85,56 @@ export function composeServiceState( * `sim-postgres` / `sim-redis` name lookup never sees them. Find the service by * the labels Compose stamps on every container instead. */ -function composeServiceContainer( - install: ComposeInstall, - service: 'db' | 'redis' -): { state: 'running' | 'stopped' } | null { - return composeServiceState( - dockerText([ - 'ps', - '-a', - '--filter', - `label=com.docker.compose.project=${install.project}`, - '--filter', - `label=com.docker.compose.service=${service}`, - '--format', - '{{.State}}', - ]) - ) +export function composeServiceQuery(project: string, service: 'db' | 'redis'): string[] { + return [ + 'ps', + '-a', + '--filter', + `label=com.docker.compose.project=${project}`, + '--filter', + `label=com.docker.compose.service=${service}`, + '--format', + '{{.State}}', + ] +} + +export interface ServiceRow { + label: string + state: ServiceState +} + +/** + * Container rows for `status`. Standalone `sim-postgres` / `sim-redis` belong to + * the dev install; each Compose stack runs its own `db` / `redis` services, which + * are looked up by label through `query` (Docker by default, a fake in tests). + */ +export function serviceStatusRows( + installs: Install[], + standalone: { db: ServiceState; redis: ServiceState }, + query: (args: string[]) => string | null = (args) => dockerText(args) +): ServiceRow[] { + const rows: ServiceRow[] = [] + const stacks = installs.filter((install): install is ComposeInstall => install.kind === 'compose') + if (stacks.length === 0 || installs.some((install) => install.kind === 'dev')) { + rows.push( + { label: `postgres (${DB_CONTAINER})`, state: standalone.db }, + { label: `redis (${REDIS_CONTAINER})`, state: standalone.redis } + ) + } + for (const stack of stacks) { + const scope = stacks.length > 1 ? `${stack.project} ` : '' + rows.push( + { + label: `${scope}postgres (compose db)`, + state: composeServiceState(query(composeServiceQuery(stack.project, 'db'))), + }, + { + label: `${scope}redis (compose redis)`, + state: composeServiceState(query(composeServiceQuery(stack.project, 'redis'))), + } + ) + } + return rows } /** Docker command whose output the user should see (up, logs); returns exit code. */ @@ -132,7 +169,7 @@ interface K8sInstall { /** False when the context's API server is outside the local allowlist — flagged before destructive ops. */ local: boolean } -type Install = ComposeInstall | DevInstall | K8sInstall +export type Install = ComposeInstall | DevInstall | K8sInstall interface ComposeProject { Name: string @@ -627,25 +664,19 @@ async function status(): Promise { return } for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`) - const containerState = (state: { state: 'running' | 'stopped' } | null) => - docker ? (state ? state.state : 'absent') : 'unknown (docker down)' - console.log() - const composeStacks = installs.filter((install) => install.kind === 'compose') - // Standalone containers belong to the dev install; Compose stacks run their own - // `db` / `redis` services, which the standalone name lookup can't see. - if (composeStacks.length === 0 || installs.some((install) => install.kind === 'dev')) { - console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`) - console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`) - } - for (const stack of composeStacks) { - const scope = composeStacks.length > 1 ? `${stack.project} ` : '' - console.log( - ` ${scope}postgres (compose db): ${containerState(composeServiceContainer(stack, 'db'))}` - ) - console.log( - ` ${scope}redis (compose redis): ${containerState(composeServiceContainer(stack, 'redis'))}` - ) + const containerState = (state: ServiceState) => { + if (!docker) return 'unknown (docker down)' + if (state === 'unknown') return 'unknown (docker query failed)' + return state ? state.state : 'absent' } + console.log() + const rows = serviceStatusRows(installs, { + db: detection.dbContainer, + redis: detection.redisContainer, + }) + const width = Math.max(...rows.map((row) => row.label.length)) + 3 + for (const row of rows) + console.log(` ${`${row.label}:`.padEnd(width)}${containerState(row.state)}`) const [app, realtime] = await Promise.all([ httpHealth(`${APP_URL}/api/health`), httpHealth(REALTIME_HEALTH),