diff --git a/packages/sim-setup/src/lifecycle.test.ts b/packages/sim-setup/src/lifecycle.test.ts index d53ce9c31a3..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 } 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', () => { @@ -17,4 +23,111 @@ 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 an empty Compose query as absent and a failed one as unknown', () => { + expect(composeServiceState('')).toBeNull() + expect(composeServiceState('\n \n')).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 ddf3047b7c1..53f781edc1f 100644 --- a/packages/sim-setup/src/lifecycle.ts +++ b/packages/sim-setup/src/lifecycle.ts @@ -61,6 +61,82 @@ 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. `null` means the + * query failed, which is not the same as "no containers", so it maps to `'unknown'`. + */ +export function composeServiceState(stateOutput: string | null): ServiceState { + if (stateOutput === null) return 'unknown' + 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. + */ +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. */ function dockerInherit(args: string[], cwd: string = ROOT): number { return spawnSync('docker', args, { cwd, stdio: 'inherit' }).status ?? 1 @@ -93,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 @@ -588,11 +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)' + 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() - console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`) - console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`) + 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),