mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
- 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>
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|