mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(docker): export/import (move a container to another machine) + boot reaper
- src/docker-export.ts: full-image export (pause-consistent commit + save|stream + workspace tar + manifest -> one .codeman-container.tgz) and workspace-only; import validates manifest + per-member sha256, traversal-guards the workspace tar, docker load + quarantine re-tag (never overwrites a local tag). Bounded by runWithConversionLimit; free-space precheck; docker rmi in finally; sealed containers refuse full-image export. - routes: POST /api/docker-cases/:name/export (background + SSE), GET/DELETE /api/docker-exports, GET download, POST /api/docker-cases/import (-> new host+case) - instance-scoped boot reaper (docker-hosts.reapOrphanedDockerContainers) wired after restoreMuxSessions; never touches another instance's containers - SSE docker:exportComplete/exportFailed/importComplete (both registries) - fix: stream pipeline in saveImageToTar so the bundle isn't truncated VERIFIED end-to-end on real docker: full export -> 326MB valid bundle -> delete case -> import -> new container runs from the quarantined image with the workspace file AND the in-image change both restored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -545,6 +545,59 @@ export async function checkDockerTmuxAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
|
||||
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
|
||||
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
|
||||
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
|
||||
*/
|
||||
export async function reapOrphanedDockerContainers(
|
||||
configDir: string,
|
||||
instance: string,
|
||||
engine: DockerEngine = 'docker'
|
||||
): Promise<string[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
const bin = engine === 'podman' ? 'podman' : 'docker';
|
||||
let rows: Array<{ name: string; inst: string }> = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
bin,
|
||||
[
|
||||
'ps',
|
||||
'-a',
|
||||
'--filter',
|
||||
'label=codeman.managed=1',
|
||||
'--format',
|
||||
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
|
||||
],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
rows = stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, inst = ''] = line.split('\t');
|
||||
return { name, inst };
|
||||
});
|
||||
} catch {
|
||||
return []; // daemon down / engine absent — nothing to reap
|
||||
}
|
||||
const cases = await readDockerCases(configDir);
|
||||
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
||||
const reaped: string[] = [];
|
||||
for (const { name, inst } of rows) {
|
||||
if (inst !== instance) continue; // only THIS instance's containers
|
||||
if (expected.has(name)) continue; // still referenced by a live case
|
||||
try {
|
||||
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
||||
reaped.push(name);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
|
||||
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude
|
||||
|
||||
Reference in New Issue
Block a user