Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion packages/sim-setup/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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' })
Comment thread
AYANscyy2 marked this conversation as resolved.
})

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<string, string | null>) =>
(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],
])
})
})
94 changes: 89 additions & 5 deletions packages/sim-setup/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>-<service>-<n>`, 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -588,11 +664,19 @@ async function status(): Promise<void> {
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),
Expand Down
Loading