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:
@@ -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}"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,10 +39,13 @@ 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',
|
||||||
@@ -55,19 +53,15 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
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' });
|
||||||
|
|||||||
Reference in New Issue
Block a user