Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@

import { logger } from '../../logger'
import { withProcessLock } from '../../process-lock'
import { ContainerNameInUseError } from '../../vm/errors'
import type { VmRuntime } from '../../vm/vm-runtime'
import type { ContainerCli } from '../container-cli'
import type { ImageLoader } from '../image-loader'
import type { ContainerSpec } from '../types'
import type { ContainerSpec, LogFn } from '../types'
import {
ContainerNotReadyError,
PathOutsideMountsError,
Expand Down Expand Up @@ -268,6 +269,155 @@ export abstract class ManagedContainer {
)
}

// ── Image / logs ────────────────────────────────────────────────

/**
* True iff the existing container's image ref matches
* `descriptor.defaultImage`. Pure predicate — never called by base
* machinery; subclasses + service layers compose it for their own
* short-circuit logic. Returns false when the container does not
* exist (caller decides whether that means "stale" or "absent").
* Treats SHA-pinned variants (`<ref>@sha256:…`) as a match so a
* registry-resolved digest doesn't read as drift.
*/
async isImageCurrent(): Promise<boolean> {
const actual = await this.deps.cli.containerImageRef(
this.descriptor.containerName,
)
if (!actual) return false
const expected = this.descriptor.defaultImage
return actual === expected || actual.startsWith(`${expected}@sha256:`)
}

/** Tail the last `n` log lines from the container. */
async getLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.deps.cli.runCommand(
['logs', '-n', String(tail), this.descriptor.containerName],
(line) => lines.push(line),
)
return lines
}
Comment thread
DaniAkash marked this conversation as resolved.

/** Stream container logs until the returned handle is invoked. */
tailLogs(onLine: LogFn): () => void {
return this.deps.cli.tailLogs(this.descriptor.containerName, onLine)
}

// ── Sibling-container one-shot ──────────────────────────────────

/**
* Run `argv` inside a throwaway sibling container built from the
* same spec as the live one (image, mounts, add-hosts, base env)
* but with `restart: 'no'`, no port mappings, no health check.
* Used for migrations, image self-tests, one-off provider installs
* — anything that should run in the same image without disturbing
* the live container. Force-removes the sibling on completion,
* including when the inner command throws or times out.
*/
async runOneShot(
argv: ReadonlyArray<string>,
opts: {
onLog?: LogFn
env?: Record<string, string>
processTimeoutMs?: number
} = {},
): Promise<ExecResult> {
return this.withLifecycleLock('run-one-shot', async () => {
const setupName = `${this.descriptor.containerName}-setup`
const liveSpec = await this.buildContainerSpec()
const setupSpec: ContainerSpec = {
...liveSpec,
name: setupName,
restart: 'no',
ports: undefined,
health: undefined,
env: { ...liveSpec.env, ...opts.env },
command: [...argv] as [string, ...string[]],
}
try {
await this.createOneShotContainer(setupSpec, opts.onLog)
const result = await this.runWithOptionalTimeout(
['start', '-a', setupName],
opts.onLog,
opts.processTimeoutMs,
)
return {
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}
} finally {
await this.deps.cli.removeContainer(
setupName,
{ force: true },
opts.onLog,
)
}
})
}

/** Create with retry-on-name-collision: nerdctl occasionally lags
* releasing a name after `rm`, so a single recreate can fail. */
private async createOneShotContainer(
spec: ContainerSpec,
onLog?: LogFn,
): Promise<void> {
const maxAttempts = 3
let attempt = 1
while (true) {
await this.deps.cli.removeContainer(spec.name, { force: true }, onLog)
await this.deps.cli.waitForContainerNameRelease(spec.name, {
timeoutMs: 10_000,
intervalMs: 100,
})
try {
await this.deps.cli.createContainer(spec, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= maxAttempts
) {
throw err
}
logger.warn('One-shot container name still in use; retrying', {
adapterId: this.descriptor.adapterId,
containerName: spec.name,
attempt,
maxAttempts,
})
attempt += 1
}
}
}

private async runWithOptionalTimeout(
args: string[],
onLog: LogFn | undefined,
timeoutMs: number | undefined,
): Promise<ExecResult> {
if (timeoutMs === undefined) return this.deps.cli.runCommand(args, onLog)
let timer: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<ExecResult>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
`One-shot exceeded timeout of ${timeoutMs}ms for ${args.join(' ')}`,
),
)
}, timeoutMs)
})
try {
return await Promise.race([
this.deps.cli.runCommand(args, onLog),
timeoutPromise,
])
} finally {
if (timer !== null) clearTimeout(timer)
}
}
Comment thread
DaniAkash marked this conversation as resolved.

// ── Execute family ──────────────────────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,179 @@ describe('ManagedContainer', () => {
expect(transitions.at(-1)).toBe('running')
})
})

describe('isImageCurrent', () => {
function attachImageRef(
deps: ReturnType<typeof makeFakeDeps>,
ref: string | null,
): void {
// biome-ignore lint/suspicious/noExplicitAny: extending the fake at runtime
;(deps.cli as any).containerImageRef = async () => ref
}

it('returns true when ref matches descriptor.defaultImage', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
attachImageRef(deps, 'docker.io/test:latest')
expect(await new TestContainer(deps).isImageCurrent()).toBe(true)
})

it('returns true for SHA-pinned variants of the expected ref', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
attachImageRef(deps, 'docker.io/test:latest@sha256:deadbeef')
expect(await new TestContainer(deps).isImageCurrent()).toBe(true)
})

it('returns false when ref differs', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
attachImageRef(deps, 'docker.io/test:older')
expect(await new TestContainer(deps).isImageCurrent()).toBe(false)
})

it('returns false when the container is missing', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
attachImageRef(deps, null)
expect(await new TestContainer(deps).isImageCurrent()).toBe(false)
})
})

describe('getLogs / tailLogs', () => {
it('getLogs collects lines from cli.runCommand', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
let captured: string[] = []
// biome-ignore lint/suspicious/noExplicitAny: extending the fake at runtime
;(deps.cli as any).runCommand = async (
args: string[],
onLog?: (line: string) => void,
) => {
captured = args
onLog?.('line-a')
onLog?.('line-b')
return { exitCode: 0, stdout: '', stderr: '' }
}
const c = new TestContainer(deps)
const lines = await c.getLogs(120)
expect(lines).toEqual(['line-a', 'line-b'])
expect(captured).toEqual(['logs', '-n', '120', 'test-container'])
})

it('tailLogs returns the unsubscribe handle from cli.tailLogs', () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
let unsubscribed = false
let receivedName: string | null = null
// biome-ignore lint/suspicious/noExplicitAny: extending the fake at runtime
;(deps.cli as any).tailLogs = (name: string, _onLine: unknown) => {
receivedName = name
return () => {
unsubscribed = true
}
}
const c = new TestContainer(deps)
const stop = c.tailLogs(() => {})
expect(receivedName).toBe('test-container')
stop()
expect(unsubscribed).toBe(true)
})
})

describe('runOneShot', () => {
type OneShotFakes = {
removed: string[]
created: ContainerSpec[]
runCalls: string[][]
runResult: { exitCode: number; stdout: string; stderr: string }
}

function attachOneShotFakes(
deps: ReturnType<typeof makeFakeDeps>,
): OneShotFakes {
const state: OneShotFakes = {
removed: [],
created: [],
runCalls: [],
runResult: { exitCode: 0, stdout: 'hi', stderr: '' },
}
const cli = deps.cli as unknown as Record<string, unknown>
cli.removeContainer = async (
name: string,
_opts?: { force?: boolean },
) => {
state.removed.push(name)
}
cli.waitForContainerNameRelease = async () => {}
cli.createContainer = async (spec: ContainerSpec) => {
state.created.push(spec)
}
cli.runCommand = async (args: string[]) => {
state.runCalls.push(args)
return state.runResult
}
return state
}

it('creates a sibling -setup container with no ports/health and the requested argv', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
const fakes = attachOneShotFakes(deps)
const c = new TestContainer(deps)

const result = await c.runOneShot(['echo', 'hello'], {
env: { EXTRA: '1' },
})

expect(result).toEqual({ exitCode: 0, stdout: 'hi', stderr: '' })
expect(fakes.created).toHaveLength(1)
const setupSpec = fakes.created[0]
expect(setupSpec.name).toBe('test-container-setup')
expect(setupSpec.image).toBe('docker.io/test:latest')
expect(setupSpec.restart).toBe('no')
expect(setupSpec.ports).toBeUndefined()
expect(setupSpec.health).toBeUndefined()
expect(setupSpec.command).toEqual(['echo', 'hello'])
expect(setupSpec.env).toEqual({ FOO: 'bar', EXTRA: '1' })
expect(fakes.runCalls).toEqual([['start', '-a', 'test-container-setup']])
// Removed twice: pre-create cleanup + final force-remove.
expect(
fakes.removed.filter((n) => n === 'test-container-setup'),
).toHaveLength(2)
})

it('force-removes the sibling even when the inner command throws', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
const fakes = attachOneShotFakes(deps)
const cli = deps.cli as unknown as Record<string, unknown>
cli.runCommand = async () => {
throw new Error('boom')
}
const c = new TestContainer(deps)
await expect(c.runOneShot(['noop'])).rejects.toThrow(/boom/)
expect(
fakes.removed.filter((n) => n === 'test-container-setup'),
).toHaveLength(2)
})

it('retries createContainer on ContainerNameInUseError', async () => {
const deps = makeFakeDeps({ lockDir: mkTempDir() })
const fakes = attachOneShotFakes(deps)
const cli = deps.cli as unknown as Record<string, unknown>
let createAttempts = 0
cli.createContainer = async (spec: ContainerSpec) => {
createAttempts += 1
if (createAttempts < 2) {
const { ContainerNameInUseError } = await import(
'../../../../src/lib/vm/errors'
)
throw new ContainerNameInUseError(
spec.name,
'nerdctl create',
1,
`container name "${spec.name}" is already used`,
)
}
fakes.created.push(spec)
}
const c = new TestContainer(deps)
await c.runOneShot(['echo'])
expect(createAttempts).toBe(2)
expect(fakes.created).toHaveLength(1)
})
})
})
Loading