Skip to content
Open
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
104 changes: 104 additions & 0 deletions src/main/docker/docker-container-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DockerEngineFake } from './docker-engine-fake'
import {
attachDockerContainer,
hibernateDockerContainer,
spawnDockerContainer,
terminateDockerContainer
} from './docker-container-lifecycle'

describe('docker-container-lifecycle', () => {
let repoPath: string
let worktreePath: string
let engine: DockerEngineFake

beforeEach(async () => {
repoPath = await mkdtemp(path.join(tmpdir(), 'orca-docker-repo-'))
worktreePath = await mkdtemp(path.join(tmpdir(), 'orca-docker-worktree-'))
engine = new DockerEngineFake()
})

afterEach(async () => {
await rm(repoPath, { recursive: true, force: true })
await rm(worktreePath, { recursive: true, force: true })
})

it('builds an image, creates a bind-mounted container, and starts it', async () => {
const result = await spawnDockerContainer({
repoPath,
worktreePath,
repoIdentity: 'repo-1',
engine,
platform: 'linux',
now: () => 456
})

expect(result.container).toMatchObject({
id: 'container-1',
imageId: 'sha256:fake-image-1',
startedAt: 456,
state: 'running'
})
expect(engine.commands.map((command) => command.command)).toEqual([
'image.build',
'container.create',
'container.start'
])
expect(engine.commands[1]).toMatchObject({
command: 'container.create',
options: { mounts: [{ source: worktreePath, target: '/workspace' }] }
})
})

it('limits image builds to one concurrent build per repo identity', async () => {
engine.buildDelayMs = 20

await Promise.all([
spawnDockerContainer({ repoPath, worktreePath, repoIdentity: 'same', engine }),
spawnDockerContainer({ repoPath, worktreePath, repoIdentity: 'same', engine })
])

expect(engine.commands.filter((command) => command.command === 'image.build')).toHaveLength(1)
})

it('attaches only running containers', async () => {
const spawned = await spawnDockerContainer({ repoPath, worktreePath, engine })

await expect(
attachDockerContainer(engine, spawned.container.id, () => 789)
).resolves.toMatchObject({
id: spawned.container.id,
state: 'running',
startedAt: 789
})

await hibernateDockerContainer(engine, spawned.container)
await expect(attachDockerContainer(engine, spawned.container.id)).rejects.toThrow(
'is not running'
)
})

it('hibernates and terminates containers', async () => {
const spawned = await spawnDockerContainer({ repoPath, worktreePath, engine })

await expect(hibernateDockerContainer(engine, spawned.container)).resolves.toMatchObject({
state: 'hibernated'
})
await expect(terminateDockerContainer(engine, spawned.container)).resolves.toMatchObject({
state: 'terminated'
})
expect(engine.commands.map((command) => command.command)).toContain('container.rm')
})

it('surfaces build failures before creating a container', async () => {
engine.nextBuildError = new Error('image build fail')

await expect(spawnDockerContainer({ repoPath, worktreePath, engine })).rejects.toThrow(
'image build fail'
)
expect(engine.commands.map((command) => command.command)).toEqual(['image.build'])
})
})
108 changes: 108 additions & 0 deletions src/main/docker/docker-container-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { DockerEngineClientLike } from './docker-engine-client'
import { buildDockerImage } from './docker-image-build'
import { DEFAULT_CONTAINER_WORKDIR, resolveDockerBindMount } from './docker-mount'
import type { DockerContainerHandle, DockerImageHandle } from './types'

const IMAGE_BUILD_TIMEOUT_MS = 60_000
const repoBuildLocks = new Map<string, Promise<DockerImageHandle>>()

export type SpawnDockerContainerOptions = {
repoPath: string
worktreePath: string
repoIdentity?: string
engine: DockerEngineClientLike
platform?: NodeJS.Platform
workdir?: string
now?: () => number
}

export type SpawnDockerContainerResult = {
image: DockerImageHandle
container: DockerContainerHandle
}

export async function spawnDockerContainer(
options: SpawnDockerContainerOptions
): Promise<SpawnDockerContainerResult> {
const workdir = options.workdir ?? DEFAULT_CONTAINER_WORKDIR
const image = await buildImageOncePerRepo(options)
const mount = resolveDockerBindMount({
hostPath: options.worktreePath,
platform: options.platform,
containerPath: workdir
})
const created = await options.engine.createContainer({
imageId: image.id,
workdir,
mounts: [mount]
})
await options.engine.startContainer(created.id)

return {
image,
container: {
id: created.id,
imageId: image.id,
startedAt: (options.now ?? Date.now)(),
state: 'running'
}
}
}

export async function attachDockerContainer(
engine: DockerEngineClientLike,
id: string,
now: () => number = Date.now
): Promise<DockerContainerHandle> {
const info = await engine.inspectContainer(id)
if (!info.running) {
throw new Error(`Docker container ${id} is not running`)
}
return {
id: info.id,
imageId: info.imageId,
startedAt: now(),
state: 'running'
}
}

export async function hibernateDockerContainer(
engine: DockerEngineClientLike,
container: DockerContainerHandle
): Promise<DockerContainerHandle> {
await engine.stopContainer(container.id)
return { ...container, state: 'hibernated' }
}

export async function terminateDockerContainer(
engine: DockerEngineClientLike,
container: DockerContainerHandle
): Promise<DockerContainerHandle> {
if (container.state !== 'terminated') {
await engine.stopContainer(container.id)
await engine.removeContainer(container.id)
}
return { ...container, state: 'terminated' }
}

async function buildImageOncePerRepo(
options: SpawnDockerContainerOptions
): Promise<DockerImageHandle> {
const key = options.repoIdentity ?? options.repoPath
const existing = repoBuildLocks.get(key)
if (existing) {
return existing
}

const next = buildDockerImage({
repoPath: options.repoPath,
repoIdentity: options.repoIdentity,
engine: options.engine,
timeoutMs: IMAGE_BUILD_TIMEOUT_MS,
now: options.now
}).finally(() => {
repoBuildLocks.delete(key)
})
repoBuildLocks.set(key, next)
return next
}
Loading
Loading