feat: convert container runtime from Apple Container to Docker (#323)

Swap container-runtime.ts to the Docker variant:
- CONTAINER_RUNTIME_BIN: 'container' → 'docker'
- readonlyMountArgs: --mount bind,readonly → -v host:container:ro
- ensureContainerRuntimeRunning: container system status → docker info
- cleanupOrphans: Apple Container JSON format → docker ps --filter
- build.sh default: container → docker

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-20 13:20:13 +02:00
committed by GitHub
parent 51a50d402b
commit 607623aa59
3 changed files with 50 additions and 100 deletions

View File

@@ -8,7 +8,7 @@ cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"

View File

@@ -32,12 +32,9 @@ beforeEach(() => {
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns --mount flag with bind,source,target,readonly', () => {
it('returns -v flag with :ro suffix', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
});
});
@@ -53,41 +50,21 @@ describe('stopContainer', () => {
describe('ensureContainerRuntimeRunning', () => {
it('does nothing when runtime is already running', () => {
// system status succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
`${CONTAINER_RUNTIME_BIN} info`,
{ stdio: 'pipe', timeout: 10000 },
);
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('starts runtime when status check fails', () => {
// system status fails
it('throws when docker info fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not running');
});
// system start succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(2);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} system start`,
{ stdio: 'pipe', timeout: 30000 },
);
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
});
it('throws when both status and start fail', () => {
mockExecSync.mockImplementation(() => {
throw new Error('failed');
throw new Error('Cannot connect to the Docker daemon');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
@@ -101,21 +78,14 @@ describe('ensureContainerRuntimeRunning', () => {
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => {
const containers = [
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
{ status: 'running', configuration: { id: 'nanoclaw-group2-222' } },
{ status: 'running', configuration: { id: 'other-container' } },
{ status: 'stopped', configuration: { id: 'nanoclaw-old-333' } },
];
// ls returns container list
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ls + 2 stop calls (only running nanoclaw- containers)
// ps + 2 stop calls
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
@@ -134,7 +104,7 @@ describe('cleanupOrphans', () => {
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce(JSON.stringify([]));
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
@@ -142,18 +112,9 @@ describe('cleanupOrphans', () => {
expect(logger.info).not.toHaveBeenCalled();
});
it('handles empty output from ls', () => {
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.warn).not.toHaveBeenCalled();
});
it('warns and continues when ls fails', () => {
it('warns and continues when ps fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('runtime not available');
throw new Error('docker not available');
});
cleanupOrphans(); // should not throw
@@ -165,12 +126,7 @@ describe('cleanupOrphans', () => {
});
it('continues stopping remaining containers when one stop fails', () => {
const containers = [
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
];
mockExecSync.mockReturnValueOnce(JSON.stringify(containers));
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');

View File

@@ -1,17 +1,17 @@
/**
* Container runtime abstraction for NanoClaw.
* Swap this file to switch between container runtimes (e.g. Docker).
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'container';
export const CONTAINER_RUNTIME_BIN = 'docker';
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
return ['-v', `${hostPath}:${containerPath}:ro`];
}
/** Returns the shell command to stop a container by name. */
@@ -22,52 +22,46 @@ export function stopContainer(name: string): string {
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000 });
logger.debug('Container runtime already running');
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure your container runtime is installed ║',
);
console.error(
'║ 2. Start the runtime, then restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Docker is installed and running ║',
);
console.error(
'║ 2. Run: docker info ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });