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" IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}" TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
echo "Building NanoClaw agent container image..." echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}" echo "Image: ${IMAGE_NAME}:${TAG}"

View File

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

View File

@@ -1,17 +1,17 @@
/** /**
* Container runtime abstraction for NanoClaw. * 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 { execSync } from 'child_process';
import { logger } from './logger.js'; import { logger } from './logger.js';
/** The container runtime binary name. */ /** 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. */ /** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { 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. */ /** Returns the shell command to stop a container by name. */
@@ -22,15 +22,10 @@ export function stopContainer(name: string): string {
/** Ensure the container runtime is running, starting it if needed. */ /** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void { export function ensureContainerRuntimeRunning(): void {
try { try {
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }); execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000 });
logger.debug('Container runtime already running'); 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) { } catch (err) {
logger.error({ err }, 'Failed to start container runtime'); logger.error({ err }, 'Failed to reach container runtime');
console.error( console.error(
'\n╔════════════════════════════════════════════════════════════════╗', '\n╔════════════════════════════════════════════════════════════════╗',
); );
@@ -44,30 +39,29 @@ export function ensureContainerRuntimeRunning(): void {
'║ Agents cannot run without a container runtime. To fix: ║', '║ Agents cannot run without a container runtime. To fix: ║',
); );
console.error( console.error(
'║ 1. Ensure your container runtime is installed ║', '║ 1. Ensure Docker is installed and running ║',
); );
console.error( console.error(
'║ 2. Start the runtime, then restart NanoClaw ║', '║ 2. Run: docker info ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
); );
console.error( console.error(
'╚════════════════════════════════════════════════════════════════╝\n', '╚════════════════════════════════════════════════════════════════╝\n',
); );
throw new Error('Container runtime is required but failed to start'); throw new Error('Container runtime is required but failed to start');
} }
}
} }
/** Kill orphaned NanoClaw containers from previous runs. */ /** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void { export function cleanupOrphans(): void {
try { try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, { const output = execSync(
stdio: ['pipe', 'pipe', 'pipe'], `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
encoding: 'utf-8', { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
}); );
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); const orphans = output.trim().split('\n').filter(Boolean);
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
for (const name of orphans) { for (const name of orphans) {
try { try {
execSync(stopContainer(name), { stdio: 'pipe' }); execSync(stopContainer(name), { stdio: 'pipe' });