Skip to content

Commit fc78ff0

Browse files
authored
fix(sim-setup): report Compose db/redis state in status (#8370)
* fix(sim-setup): report Compose db/redis state in status * fix(sim-setup): report failed Compose probes as unknown and test status rows
1 parent 35efc8a commit fc78ff0

2 files changed

Lines changed: 203 additions & 6 deletions

File tree

‎packages/sim-setup/src/lifecycle.test.ts‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { tmpdir } from 'node:os'
33
import path from 'node:path'
44
import { describe, expect, it } from 'vitest'
55
import { ensureProductionComposeFile } from './compose-asset'
6-
import { composeInstallFromDirectory } from './lifecycle'
6+
import {
7+
composeInstallFromDirectory,
8+
composeServiceQuery,
9+
composeServiceState,
10+
type Install,
11+
serviceStatusRows,
12+
} from './lifecycle'
713

814
describe('setup lifecycle', () => {
915
it('does not duplicate a running install restored from its directory', () => {
@@ -17,4 +23,111 @@ describe('setup lifecycle', () => {
1723
rmSync(root, { recursive: true, force: true })
1824
}
1925
})
26+
27+
it('reads a Compose service as running when any replica is running', () => {
28+
expect(composeServiceState('running')).toEqual({ state: 'running' })
29+
expect(composeServiceState('exited\nrunning\n')).toEqual({ state: 'running' })
30+
})
31+
32+
it('reads a Compose service with only stopped containers as stopped', () => {
33+
expect(composeServiceState('exited')).toEqual({ state: 'stopped' })
34+
expect(composeServiceState('created\nexited')).toEqual({ state: 'stopped' })
35+
})
36+
37+
it('reads an empty Compose query as absent and a failed one as unknown', () => {
38+
expect(composeServiceState('')).toBeNull()
39+
expect(composeServiceState('\n \n')).toBeNull()
40+
expect(composeServiceState(null)).toBe('unknown')
41+
})
42+
})
43+
44+
describe('status service rows', () => {
45+
const compose = (project: string): Install => ({
46+
kind: 'compose',
47+
file: `/srv/${project}/docker-compose.prod.yml`,
48+
dir: `/srv/${project}`,
49+
project,
50+
})
51+
const dev: Install = { kind: 'dev', postgres: true, redis: true }
52+
const standalone = { db: { state: 'running' as const }, redis: null }
53+
54+
/** Answers `docker ps` by the project/service labels in the query, like Docker would. */
55+
const fakeDocker =
56+
(states: Record<string, string | null>) =>
57+
(args: string[]): string | null => {
58+
const project = args.find((a) => a.startsWith('label=com.docker.compose.project='))
59+
const service = args.find((a) => a.startsWith('label=com.docker.compose.service='))
60+
const key = `${project?.split('=').pop()}/${service?.split('=').pop()}`
61+
return key in states ? states[key]! : ''
62+
}
63+
64+
it('queries Compose services by project and service label', () => {
65+
expect(composeServiceQuery('sim-abc', 'db')).toEqual([
66+
'ps',
67+
'-a',
68+
'--filter',
69+
'label=com.docker.compose.project=sim-abc',
70+
'--filter',
71+
'label=com.docker.compose.service=db',
72+
'--format',
73+
'{{.State}}',
74+
])
75+
})
76+
77+
it('shows only Compose rows for a Compose install', () => {
78+
const rows = serviceStatusRows(
79+
[compose('sim-abc')],
80+
standalone,
81+
fakeDocker({ 'sim-abc/db': 'running', 'sim-abc/redis': 'exited' })
82+
)
83+
expect(rows).toEqual([
84+
{ label: 'postgres (compose db)', state: { state: 'running' } },
85+
{ label: 'redis (compose redis)', state: { state: 'stopped' } },
86+
])
87+
})
88+
89+
it('reports a failed Compose query as unknown, not absent', () => {
90+
const rows = serviceStatusRows([compose('sim-abc')], standalone, () => null)
91+
expect(rows.map((row) => row.state)).toEqual(['unknown', 'unknown'])
92+
})
93+
94+
it('keeps standalone rows when a dev install is present', () => {
95+
const rows = serviceStatusRows(
96+
[dev, compose('sim-abc')],
97+
standalone,
98+
fakeDocker({ 'sim-abc/db': 'running', 'sim-abc/redis': 'running' })
99+
)
100+
expect(rows.map((row) => row.label)).toEqual([
101+
'postgres (sim-postgres)',
102+
'redis (sim-redis)',
103+
'postgres (compose db)',
104+
'redis (compose redis)',
105+
])
106+
expect(rows[0]!.state).toEqual({ state: 'running' })
107+
expect(rows[1]!.state).toBeNull()
108+
})
109+
110+
it('never queries Compose labels without a Compose install', () => {
111+
let queried = false
112+
const rows = serviceStatusRows([dev], standalone, () => {
113+
queried = true
114+
return ''
115+
})
116+
expect(queried).toBe(false)
117+
expect(rows.map((row) => row.label)).toEqual(['postgres (sim-postgres)', 'redis (sim-redis)'])
118+
})
119+
120+
it('prefixes rows with the project when several Compose stacks run', () => {
121+
const rows = serviceStatusRows(
122+
[compose('sim-a'), compose('sim-b')],
123+
standalone,
124+
fakeDocker({ 'sim-a/db': 'running', 'sim-b/db': '' })
125+
)
126+
expect(rows.map((row) => [row.label, row.state])).toEqual([
127+
['sim-a postgres (compose db)', { state: 'running' }],
128+
['sim-a redis (compose redis)', null],
129+
['sim-b postgres (compose db)', null],
130+
['sim-b redis (compose redis)', null],
131+
])
132+
})
20133
})

‎packages/sim-setup/src/lifecycle.ts‎

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,82 @@ function dockerText(args: string[], cwd: string = ROOT): string | null {
6161
return result.status === 0 ? result.stdout.trim() : null
6262
}
6363

64+
/** A service's container state, or `'unknown'` when the Docker query itself failed. */
65+
export type ServiceState = { state: 'running' | 'stopped' } | null | 'unknown'
66+
67+
/**
68+
* Reduces `docker ps --format '{{.State}}'` output for one Compose service to the
69+
* same shape `runDetection` uses for standalone containers. A scaled service can
70+
* have several replicas; any running replica counts as running. `null` means the
71+
* query failed, which is not the same as "no containers", so it maps to `'unknown'`.
72+
*/
73+
export function composeServiceState(stateOutput: string | null): ServiceState {
74+
if (stateOutput === null) return 'unknown'
75+
const states = stateOutput
76+
.split('\n')
77+
.map((state) => state.trim())
78+
.filter(Boolean)
79+
if (states.length === 0) return null
80+
return { state: states.includes('running') ? 'running' : 'stopped' }
81+
}
82+
83+
/**
84+
* Compose names containers `<project>-<service>-<n>`, so the standalone
85+
* `sim-postgres` / `sim-redis` name lookup never sees them. Find the service by
86+
* the labels Compose stamps on every container instead.
87+
*/
88+
export function composeServiceQuery(project: string, service: 'db' | 'redis'): string[] {
89+
return [
90+
'ps',
91+
'-a',
92+
'--filter',
93+
`label=com.docker.compose.project=${project}`,
94+
'--filter',
95+
`label=com.docker.compose.service=${service}`,
96+
'--format',
97+
'{{.State}}',
98+
]
99+
}
100+
101+
export interface ServiceRow {
102+
label: string
103+
state: ServiceState
104+
}
105+
106+
/**
107+
* Container rows for `status`. Standalone `sim-postgres` / `sim-redis` belong to
108+
* the dev install; each Compose stack runs its own `db` / `redis` services, which
109+
* are looked up by label through `query` (Docker by default, a fake in tests).
110+
*/
111+
export function serviceStatusRows(
112+
installs: Install[],
113+
standalone: { db: ServiceState; redis: ServiceState },
114+
query: (args: string[]) => string | null = (args) => dockerText(args)
115+
): ServiceRow[] {
116+
const rows: ServiceRow[] = []
117+
const stacks = installs.filter((install): install is ComposeInstall => install.kind === 'compose')
118+
if (stacks.length === 0 || installs.some((install) => install.kind === 'dev')) {
119+
rows.push(
120+
{ label: `postgres (${DB_CONTAINER})`, state: standalone.db },
121+
{ label: `redis (${REDIS_CONTAINER})`, state: standalone.redis }
122+
)
123+
}
124+
for (const stack of stacks) {
125+
const scope = stacks.length > 1 ? `${stack.project} ` : ''
126+
rows.push(
127+
{
128+
label: `${scope}postgres (compose db)`,
129+
state: composeServiceState(query(composeServiceQuery(stack.project, 'db'))),
130+
},
131+
{
132+
label: `${scope}redis (compose redis)`,
133+
state: composeServiceState(query(composeServiceQuery(stack.project, 'redis'))),
134+
}
135+
)
136+
}
137+
return rows
138+
}
139+
64140
/** Docker command whose output the user should see (up, logs); returns exit code. */
65141
function dockerInherit(args: string[], cwd: string = ROOT): number {
66142
return spawnSync('docker', args, { cwd, stdio: 'inherit' }).status ?? 1
@@ -93,7 +169,7 @@ interface K8sInstall {
93169
/** False when the context's API server is outside the local allowlist — flagged before destructive ops. */
94170
local: boolean
95171
}
96-
type Install = ComposeInstall | DevInstall | K8sInstall
172+
export type Install = ComposeInstall | DevInstall | K8sInstall
97173

98174
interface ComposeProject {
99175
Name: string
@@ -588,11 +664,19 @@ async function status(): Promise<void> {
588664
return
589665
}
590666
for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`)
591-
const containerState = (state: { state: 'running' | 'stopped' } | null) =>
592-
docker ? (state ? state.state : 'absent') : 'unknown (docker down)'
667+
const containerState = (state: ServiceState) => {
668+
if (!docker) return 'unknown (docker down)'
669+
if (state === 'unknown') return 'unknown (docker query failed)'
670+
return state ? state.state : 'absent'
671+
}
593672
console.log()
594-
console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`)
595-
console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`)
673+
const rows = serviceStatusRows(installs, {
674+
db: detection.dbContainer,
675+
redis: detection.redisContainer,
676+
})
677+
const width = Math.max(...rows.map((row) => row.label.length)) + 3
678+
for (const row of rows)
679+
console.log(` ${`${row.label}:`.padEnd(width)}${containerState(row.state)}`)
596680
const [app, realtime] = await Promise.all([
597681
httpHealth(`${APP_URL}/api/health`),
598682
httpHealth(REALTIME_HEALTH),

0 commit comments

Comments
 (0)