mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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:
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* @fileoverview Docker case export / import: move a container (toolchain + any
|
||||
* in-image changes) PLUS its workspace to another machine as one portable
|
||||
* `.codeman-container.tgz`, and restore it.
|
||||
*
|
||||
* A full-image export = `docker commit` the running container to an image ->
|
||||
* `docker save` that image -> tar the bind-mounted workspace -> a manifest, all
|
||||
* bundled into one gzip tarball. A workspace-only export skips the image (fast,
|
||||
* files-only). Import validates the manifest + per-member checksums, extracts the
|
||||
* workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it
|
||||
* into a quarantined namespace (never overwriting a local tag), and hands the
|
||||
* caller enough to recreate a hardened case on the destination.
|
||||
*
|
||||
* Safety (all from the design critic): pause the container spanning the workspace
|
||||
* tar AND the commit so the two artifacts are mutually consistent; a free-space
|
||||
* precheck (a full docker graph wedges EVERY session on the host); `docker rmi`
|
||||
* the intermediate image in a finally; sealed containers refuse a full-image
|
||||
* export (an in-container login would ride the committed layer); import rejects
|
||||
* absolute / `..` tar members and checksum mismatches. Bounded by
|
||||
* runWithConversionLimit so N exports cannot fork-bomb the host.
|
||||
*
|
||||
* @module docker-export
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { DockerEngine, SessionDocker } from './types.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */
|
||||
export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
|
||||
/** Manifest schema version (bump on any breaking field change). */
|
||||
export const DOCKER_EXPORT_SCHEMA = 1;
|
||||
|
||||
export type DockerExportMode = 'full' | 'workspace';
|
||||
|
||||
export interface DockerExportManifest {
|
||||
schemaVersion: number;
|
||||
caseName: string;
|
||||
mode: DockerExportMode;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerWorkdir: string;
|
||||
network: string;
|
||||
createdAt: number;
|
||||
codemanVersion: string;
|
||||
mountCredentials: boolean;
|
||||
/** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */
|
||||
secretFree: boolean;
|
||||
/** sha256 of each bundle member that is present. */
|
||||
checksums: { image?: string; workspace?: string };
|
||||
}
|
||||
|
||||
// ========== Pure helpers (unit-tested) ==========
|
||||
|
||||
/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */
|
||||
export function dockerArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/** Portable bundle filename for a case export. */
|
||||
export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string {
|
||||
const suffix = mode === 'workspace' ? 'workspace' : 'container';
|
||||
return `${caseName}-${timestamp}.codeman-${suffix}.tgz`;
|
||||
}
|
||||
|
||||
/** Quarantined image tag for an imported bundle (never overwrites a local tag). */
|
||||
export function importedImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/imported-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */
|
||||
export function exportImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/export-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a tar member path that would escape the extraction root (absolute path
|
||||
* or a `..` component). The import-side traversal guard.
|
||||
*/
|
||||
export function isSafeTarMember(member: string): boolean {
|
||||
const trimmed = member.trim();
|
||||
if (!trimmed || trimmed === './') return true;
|
||||
if (trimmed.startsWith('/')) return false;
|
||||
// Normalize separators and check each component.
|
||||
return !trimmed.split('/').some((part) => part === '..');
|
||||
}
|
||||
|
||||
/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */
|
||||
export function parseLoadedImageRef(loadOutput: string): string | null {
|
||||
const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i);
|
||||
if (idMatch) return idMatch[1];
|
||||
const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i);
|
||||
if (refMatch) return refMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== IO helpers ==========
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { timeout?: number } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
if (opts.timeout) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${cmd} timed out after ${opts.timeout}ms`));
|
||||
}, opts.timeout);
|
||||
}
|
||||
child.stdout.on('data', (d) => (stdout += d));
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `docker save <tag>` stdout to a raw tar file (no shell, no double-gzip).
|
||||
* Uses stream `pipeline` so completion means the write stream is FULLY flushed to
|
||||
* disk (a naive child 'close' resolves before the last chunks land, truncating the
|
||||
* file — a real bug caught in end-to-end testing), AND waits for a clean exit code.
|
||||
*/
|
||||
async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise<void> {
|
||||
const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`))
|
||||
);
|
||||
});
|
||||
// pipeline resolves only after the destination has fully flushed.
|
||||
await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]);
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('data', (d) => hash.update(d));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function freeBytes(path: string): Promise<number> {
|
||||
try {
|
||||
const stat = await fs.statfs(path);
|
||||
return Number(stat.bavail) * Number(stat.bsize);
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY; // statfs unsupported — don't block
|
||||
}
|
||||
}
|
||||
|
||||
async function isContainerRunning(argv: string[], container: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
bundlePath: string;
|
||||
manifest: DockerExportManifest;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a docker case to a portable bundle. Bounded by runWithConversionLimit.
|
||||
* `full` mode commits + saves the image AND tars the workspace; `workspace` mode
|
||||
* tars just the workspace. The container is paused across the artifact capture so
|
||||
* image and workspace are mutually consistent.
|
||||
*/
|
||||
export async function exportDockerCase(params: {
|
||||
docker: SessionDocker;
|
||||
caseName: string;
|
||||
timestamp: number;
|
||||
exportsDir: string;
|
||||
mode: DockerExportMode;
|
||||
codemanVersion: string;
|
||||
}): Promise<ExportResult> {
|
||||
const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params;
|
||||
|
||||
if (mode === 'full' && !docker.mountCredentials) {
|
||||
throw new Error(
|
||||
'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
// No real docker/tar under vitest — return a deterministic stub.
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
};
|
||||
return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 };
|
||||
}
|
||||
|
||||
return runWithConversionLimit(async () => {
|
||||
if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
|
||||
|
||||
const free = await freeBytes(exportsDir);
|
||||
if (free < DOCKER_EXPORT_MIN_FREE_BYTES) {
|
||||
throw new Error(
|
||||
`not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.`
|
||||
);
|
||||
}
|
||||
|
||||
const argv = dockerArgv(docker);
|
||||
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
|
||||
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
const wasRunning = await isContainerRunning(argv, docker.containerName);
|
||||
let commitTag: string | undefined;
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const checksums: DockerExportManifest['checksums'] = {};
|
||||
|
||||
if (mode === 'full') {
|
||||
commitTag = exportImageTag(caseName, timestamp);
|
||||
// Blank instance-specific committed env so the image carries no stale host refs.
|
||||
await run(
|
||||
argv[0],
|
||||
[
|
||||
...argv.slice(1),
|
||||
'commit',
|
||||
'-c',
|
||||
'ENV CODEMAN_API_URL=',
|
||||
'-c',
|
||||
'ENV CODEMAN_HOOK_SECRET_FILE=',
|
||||
docker.containerName,
|
||||
commitTag,
|
||||
],
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
await saveImageToTar(argv, commitTag, imageTar);
|
||||
checksums.image = await sha256File(imageTar);
|
||||
}
|
||||
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 });
|
||||
checksums.workspace = await sha256File(workspaceTar);
|
||||
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
// Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free.
|
||||
secretFree: docker.mountCredentials,
|
||||
checksums,
|
||||
};
|
||||
await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const members =
|
||||
mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar'];
|
||||
await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 });
|
||||
|
||||
const stat = await fs.stat(bundlePath);
|
||||
return { bundlePath, manifest, sizeBytes: stat.size };
|
||||
} finally {
|
||||
// Always remove the intermediate image + stage dir, and unpause.
|
||||
if (commitTag) {
|
||||
await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {});
|
||||
}
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
manifest: DockerExportManifest;
|
||||
/** Quarantined image ref the destination case should use (full mode only). */
|
||||
importedImage?: string;
|
||||
/** Directory the workspace was extracted into. */
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle produced by exportDockerCase: validate the manifest + per-member
|
||||
* checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in
|
||||
* full mode, `docker load` the image and re-tag it into a quarantined namespace.
|
||||
*/
|
||||
export async function importDockerBundle(params: {
|
||||
bundlePath: string;
|
||||
destWorkspace: string;
|
||||
engine: DockerEngine;
|
||||
timestamp: number;
|
||||
}): Promise<ImportResult> {
|
||||
const { bundlePath, destWorkspace, engine, timestamp } = params;
|
||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
|
||||
}
|
||||
|
||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
try {
|
||||
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
|
||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
|
||||
// Integrity: verify checksums before trusting any member.
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
if (manifest.checksums.workspace) {
|
||||
const actual = await sha256File(workspaceTar);
|
||||
if (actual !== manifest.checksums.workspace)
|
||||
throw new Error('workspace checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
|
||||
// Traversal guard: reject absolute / `..` members before extraction.
|
||||
const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 });
|
||||
for (const member of memberList.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`);
|
||||
}
|
||||
mkdirSync(destWorkspace, { recursive: true });
|
||||
await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 });
|
||||
|
||||
let importedImage: string | undefined;
|
||||
if (manifest.mode === 'full') {
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
if (manifest.checksums.image) {
|
||||
const actual = await sha256File(imageTar);
|
||||
if (actual !== manifest.checksums.image)
|
||||
throw new Error('image checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||
const loadedRef = parseLoadedImageRef(stdout);
|
||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
|
||||
importedImage = importedImageTag(manifest.caseName, timestamp);
|
||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||
}
|
||||
|
||||
return { manifest, importedImage, workspacePath: destWorkspace };
|
||||
} finally {
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** List export bundles in the exports dir (newest first), with size + mtime. */
|
||||
export async function listDockerExports(
|
||||
exportsDir: string
|
||||
): Promise<Array<{ name: string; sizeBytes: number; mtimeMs: number }>> {
|
||||
if (!existsSync(exportsDir)) return [];
|
||||
const entries = await fs.readdir(exportsDir).catch(() => [] as string[]);
|
||||
const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.tgz')) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(exportsDir, name));
|
||||
out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -474,6 +474,9 @@ const SSE_EVENTS = {
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
@@ -20,7 +21,10 @@ import {
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
} from '../schemas.js';
|
||||
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
@@ -51,6 +55,17 @@ import {
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
|
||||
/** App version for export manifests (best-effort read of package.json). */
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
@@ -376,6 +391,136 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Docker export / import ==========
|
||||
|
||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||
// save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
|
||||
app.post('/api/docker-cases/:name/export', async (req): Promise<ApiResponse<{ started: true; bundle: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const bundle = exportBundleName(name, timestamp, mode);
|
||||
// Fire-and-forget: the client watches for the SSE completion event.
|
||||
void exportDockerCase({
|
||||
docker: sessionDocker,
|
||||
caseName: name,
|
||||
timestamp,
|
||||
exportsDir: DOCKER_EXPORTS_DIR,
|
||||
mode,
|
||||
codemanVersion: APP_VERSION,
|
||||
})
|
||||
.then((result) => {
|
||||
ctx.broadcast(SseEvent.DockerExportComplete, {
|
||||
name,
|
||||
bundle: basename(result.bundlePath),
|
||||
sizeBytes: result.sizeBytes,
|
||||
mode,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
|
||||
});
|
||||
|
||||
return { success: true, data: { started: true, bundle } };
|
||||
});
|
||||
|
||||
app.get('/api/docker-exports', async (): Promise<ApiResponse<{ exports: unknown[] }>> => {
|
||||
return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
|
||||
});
|
||||
|
||||
// Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
|
||||
app.get('/api/docker-exports/:filename', async (req, reply) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
reply.header('Content-Type', 'application/gzip');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(createReadStream(full));
|
||||
});
|
||||
|
||||
app.delete('/api/docker-exports/:filename', async (req): Promise<ApiResponse<{ filename: string }>> => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
|
||||
}
|
||||
const full = join(DOCKER_EXPORTS_DIR, filename);
|
||||
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
|
||||
await fs.rm(full, { force: true });
|
||||
return { success: true, data: { filename } };
|
||||
});
|
||||
|
||||
// Import a bundle (already present in the exports dir) into a NEW docker case.
|
||||
app.post('/api/docker-cases/import', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
|
||||
const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
|
||||
if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
|
||||
|
||||
// Name-collision guard across ALL case kinds.
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === newCaseName) ||
|
||||
linkedCases[newCaseName] ||
|
||||
existsSync(join(CASES_DIR, newCaseName))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create a dedicated docker host pointing at the quarantined imported image
|
||||
// (full mode) or the manifest's base image (workspace-only).
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (!hosts.some((h) => h.id === hostId)) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
||||
...hosts,
|
||||
{
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
||||
? result.manifest.network
|
||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
hostId,
|
||||
hostWorkspacePath: destWorkspacePath,
|
||||
containerWorkdir: result.manifest.containerWorkdir,
|
||||
};
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
|
||||
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
@@ -465,6 +465,26 @@ export const DockerCaseLinkSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerExportSchema = z.object({
|
||||
mode: z.enum(['full', 'workspace']).optional(),
|
||||
});
|
||||
|
||||
export const DockerImportSchema = z.object({
|
||||
// A bare filename resolved WITHIN the exports dir (never an arbitrary path).
|
||||
bundle: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'),
|
||||
newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
destWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Destination path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
@@ -1941,6 +1941,20 @@ export class WebServer extends EventEmitter {
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
if (!this.testMode) {
|
||||
await this.restoreMuxSessions();
|
||||
|
||||
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
|
||||
// THIS instance whose case is gone from docker-cases.json (best-effort, never
|
||||
// touches another instance's containers). Runs after restore so containers
|
||||
// still referenced by a restored session are preserved.
|
||||
void import('../docker-hosts.js')
|
||||
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
|
||||
.then((reaped) => {
|
||||
if (reaped.length > 0)
|
||||
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort — daemon may be absent */
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up stale sessions from state file that don't have active mux sessions
|
||||
|
||||
@@ -370,6 +370,14 @@ export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
// ─── Docker cases ────────────────────────────────────────────────────────────
|
||||
/** A docker case export bundle finished writing. */
|
||||
export const DockerExportComplete = 'docker:exportComplete' as const;
|
||||
/** A docker case export failed. */
|
||||
export const DockerExportFailed = 'docker:exportFailed' as const;
|
||||
/** A docker bundle was imported into a new case. */
|
||||
export const DockerImportComplete = 'docker:importComplete' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -551,4 +559,9 @@ export const SseEvent = {
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseOrderChanged,
|
||||
|
||||
// Docker cases
|
||||
DockerExportComplete,
|
||||
DockerExportFailed,
|
||||
DockerImportComplete,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Unit tests for the pure docker export/import helpers (src/docker-export.ts).
|
||||
* The IO paths no-op under VITEST; these cover the naming, tar-traversal guard,
|
||||
* load-output parsing, and the sealed-mode refusal.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dockerArgv,
|
||||
exportBundleName,
|
||||
exportImageTag,
|
||||
importedImageTag,
|
||||
isSafeTarMember,
|
||||
parseLoadedImageRef,
|
||||
exportDockerCase,
|
||||
DOCKER_EXPORT_SCHEMA,
|
||||
} from '../src/docker-export.js';
|
||||
import { toSessionDocker } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('dockerArgv', () => {
|
||||
it('is raw (unescaped) argv for spawn', () => {
|
||||
expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']);
|
||||
expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([
|
||||
'podman',
|
||||
'--context',
|
||||
'ctx',
|
||||
'-H',
|
||||
'ssh://h',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundle / tag naming', () => {
|
||||
it('names bundles by case + timestamp + mode', () => {
|
||||
expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz');
|
||||
expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz');
|
||||
});
|
||||
it('quarantines imported images and tags export intermediates uniquely', () => {
|
||||
expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99');
|
||||
expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeTarMember (import traversal guard)', () => {
|
||||
it('accepts normal relative members', () => {
|
||||
expect(isSafeTarMember('./')).toBe(true);
|
||||
expect(isSafeTarMember('src/index.ts')).toBe(true);
|
||||
expect(isSafeTarMember('./a/b/c.txt')).toBe(true);
|
||||
});
|
||||
it('rejects absolute and parent-escaping members', () => {
|
||||
expect(isSafeTarMember('/etc/passwd')).toBe(false);
|
||||
expect(isSafeTarMember('../outside')).toBe(false);
|
||||
expect(isSafeTarMember('a/../../b')).toBe(false);
|
||||
expect(isSafeTarMember('./../../x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLoadedImageRef', () => {
|
||||
it('parses "Loaded image ID: sha256:..."', () => {
|
||||
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
||||
});
|
||||
it('parses "Loaded image: repo:tag"', () => {
|
||||
expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234');
|
||||
});
|
||||
it('returns null on unrecognized output', () => {
|
||||
expect(parseLoadedImageRef('some other text')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportDockerCase (VITEST stub)', () => {
|
||||
it('returns a deterministic stub manifest without touching docker', async () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA);
|
||||
expect(res.manifest.caseName).toBe('myproj');
|
||||
expect(res.manifest.mode).toBe('full');
|
||||
expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz');
|
||||
});
|
||||
|
||||
it('refuses a full-image export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
await expect(
|
||||
exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
})
|
||||
).rejects.toThrow(/sealed/);
|
||||
});
|
||||
|
||||
it('allows a workspace-only export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'workspace',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.mode).toBe('workspace');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user