controller: .fab volume legs stream via docker cp (fixes the containerized path-strand data loss, IA finding 1 HIGH) — zero shared paths both deployment shapes; export fails LOUD on any missing/empty claimed tar (no hollow bundles); import validates BEFORE it destroys (hollow bundle → app untouched); docker_run_volume_path_gate.py extinguishes the class (Tier-1/2 mounts documented host-visible)

Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
2026-07-13 10:42:02 +02:00
parent 811a0ef7a7
commit 466f42708e
5 changed files with 489 additions and 46 deletions
+112 -22
View File
@@ -2,6 +2,7 @@ package appexport
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
@@ -324,7 +325,10 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
} else {
e.debugf("exporting Docker volumes for %s", req.StackName)
e.exportVolumeData(req.StackName, dataDir, manifest)
if err := e.exportVolumeData(req.StackName, dataDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("Kötet mentése sikertelen: %v", err))
return
}
e.debugf("volume data exported: volumes=%v hasData=%v", manifest.VolumeNames, manifest.HasVolumeData)
}
e.debugf("step 3 (user data) done in %v", time.Since(stepStart))
@@ -336,6 +340,13 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
job.setStep(step, "running", "")
stepStart = time.Now()
// v0.125.0 fail-loud guard (scenario B): never package a bundle whose manifest claims data
// that is not actually in the staging tree.
if err := assertBundleDataComplete(tmpDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("A csomag hiányos lenne — az export leállt: %v", err))
return
}
// Calculate total size
manifest.TotalSizeBytes = calcDirSize(tmpDir)
e.debugf("total bundle content size: %s (%d bytes)", humanizeBytes(manifest.TotalSizeBytes), manifest.TotalSizeBytes)
@@ -583,14 +594,17 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
continue
}
subdir := filepath.Base(mount)
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
tarPath := filepath.Join(hddDir, subdir+".tar")
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
tarStart := time.Now()
if err := tarDirectory(mount, tarPath); err != nil {
e.logger.Printf("[WARN] Export: failed to tar %s: %v", mount, err)
// v0.125.0: claim the subdir ONLY on success — a claimed-but-absent tar would trip
// the packaging assertion; an honestly-skipped mount stays out of the manifest.
e.logger.Printf("[WARN] Export: failed to tar %s (excluded from the bundle): %v", mount, err)
os.Remove(tarPath)
} else {
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("HDD tar complete: %s (%s) in %v", subdir, humanizeBytes(info.Size()), time.Since(tarStart))
}
@@ -599,8 +613,76 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
manifest.HasHDDData = len(manifest.HDDSubdirs) > 0
}
// exportVolumeData exports Docker named volumes for apps without HDD storage.
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) {
// dockerExec is the docker-CLI seam (v0.125.0): runs `docker args...` with optional
// stdin/stdout STREAMING and returns captured stderr (truncated). The volume legs stream tars
// over the docker API (docker cp) — NEVER via `docker run -v <controller-path>` host mounts,
// which the daemon resolves against the GUEST filesystem and silently strands the tar when the
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
// inject a recorder (no docker on test boxes).
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdin = stdin
cmd.Stdout = stdout
var errBuf bytes.Buffer
cmd.Stderr = &errBuf
err := cmd.Run()
stderr := strings.TrimSpace(errBuf.String())
if len(stderr) > 500 {
stderr = stderr[:500] + "..."
}
return stderr, err
}
// withVolumeHelper creates a stopped helper container pinning volName at /vol, runs fn(cid),
// and ALWAYS force-removes the helper — including on fn failure (no leaked alpine containers).
func (e *Exporter) withVolumeHelper(volName string, fn func(cid string) error) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
var cidBuf bytes.Buffer
stderr, err := dockerExec(ctx, nil, &cidBuf, "create", "-v", volName+":/vol", "alpine", "true")
cancel()
if err != nil {
return fmt.Errorf("creating helper container for volume %s: %s — %w", volName, stderr, err)
}
cid := strings.TrimSpace(cidBuf.String())
defer func() {
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer rmCancel()
if _, rmErr := dockerExec(rmCtx, nil, nil, "rm", "-f", cid); rmErr != nil {
e.logger.Printf("[WARN] appexport: helper container %s cleanup failed: %v", cid, rmErr)
}
}()
return fn(cid)
}
// exportVolumeTar streams one volume's content into tarPath via `docker cp <cid>:/vol/. -`
// (tar on stdout — zero shared paths; live-probed 2026-07-13: content, subdirs, symlinks,
// empty files and uid/gid all round-trip).
func (e *Exporter) exportVolumeTar(volName, tarPath string) error {
return e.withVolumeHelper(volName, func(cid string) error {
f, err := os.Create(tarPath)
if err != nil {
return fmt.Errorf("creating %s: %w", tarPath, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
stderr, err := dockerExec(ctx, nil, f, "cp", cid+":/vol/.", "-")
closeErr := f.Close()
if err != nil {
os.Remove(tarPath)
return fmt.Errorf("streaming volume %s: %s — %w", volName, stderr, err)
}
if closeErr != nil {
os.Remove(tarPath)
return fmt.Errorf("flushing %s: %w", tarPath, closeErr)
}
return nil
})
}
// exportVolumeData exports Docker named volumes for apps without HDD storage. v0.125.0: a
// failed volume export is FATAL (export must never report success on a hollow bundle — the
// pre-fix WARN+continue is exactly how the data-loss bundles were born).
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) error {
volDir := filepath.Join(dataDir, "volumes")
os.MkdirAll(volDir, 0755)
@@ -608,28 +690,15 @@ func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifes
e.debugf("Docker volumes for %s: %v (%d total)", stackName, volumes, len(volumes))
if len(volumes) == 0 {
e.debugf("no Docker volumes — skipping volume data export")
return
return nil
}
for _, volName := range volumes {
tarPath := filepath.Join(volDir, volName+".tar")
e.debugf("exporting volume %s via docker run alpine tar...", volName)
e.debugf("exporting volume %s via docker cp streaming...", volName)
volStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", volDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar", "-C", "/vol", ".")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
e.logger.Printf("[WARN] Export: volume %s export failed: %s — %v",
volName, strings.TrimSpace(string(out)), err)
e.debugf("volume %s export failed: %s", volName, strings.TrimSpace(string(out)))
os.Remove(tarPath)
continue
if err := e.exportVolumeTar(volName, tarPath); err != nil {
return fmt.Errorf("volume %s export failed: %w", volName, err)
}
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("volume %s exported: %s in %v", volName, humanizeBytes(info.Size()), time.Since(volStart))
@@ -637,6 +706,27 @@ func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifes
manifest.VolumeNames = append(manifest.VolumeNames, volName)
}
manifest.HasVolumeData = len(manifest.VolumeNames) > 0
return nil
}
// assertBundleDataComplete is the fail-loud post-export guard (v0.125.0, scenario B): every
// manifest-CLAIMED data tar must exist non-empty in the staging tree before packaging. A
// mismatch aborts the export — yesterday's outcome ("success" with a hollow bundle) is the
// one this exists to make impossible.
func assertBundleDataComplete(tmpDir string, manifest *Manifest) error {
for _, v := range manifest.VolumeNames {
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("bundle assertion: volume %q is claimed by the manifest but its tar is missing or empty", v)
}
}
for _, s := range manifest.HDDSubdirs {
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("bundle assertion: HDD subdir %q is claimed by the manifest but its tar is missing or empty", s)
}
}
return nil
}
// createTarGz creates a gzipped tar archive of a directory.
+65 -20
View File
@@ -326,6 +326,16 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) {
job.mu.Unlock()
e.logger.Printf("[INFO] Import: opening bundle for %s (%s)", manifest.AppName, manifest.DisplayName)
// v0.125.0 validate-before-destroy (scenario C): every manifest-claimed data tar must be
// present and non-empty BEFORE the app is stopped and BEFORE any volume is removed. Hollow
// bundles (a containerized v<=0.124.0 exporter stranded the tars host-side) land HERE — the
// pre-fix order wiped the volumes first and only then discovered the emptiness.
if err := validateBundleData(tmpDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("A csomag hiányos — az importálás el sem indult, a meglévő alkalmazás érintetlen. (%v) A csomagot valószínűleg egy régebbi (≤0.124.0), konténerben futó vezérlő exportálta — készíts friss exportot.", err))
return
}
e.debugf("step 0 (open bundle) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
@@ -597,9 +607,9 @@ func (e *Exporter) restoreHDDData(tmpDir string, manifest *Manifest, composePath
tarPath := filepath.Join(hddDir, subdir+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
e.logger.Printf("[WARN] Import: HDD tar not found: %s", tarPath)
e.debugf("restoreHDDData: tar not found: %s", tarPath)
continue
// v0.125.0: a claimed-but-absent tar is an ERROR (validateBundleData already refused
// it pre-destroy; this is defense in depth, not a reachable soft path).
return fmt.Errorf("HDD tar missing from bundle: %s", subdir+".tar")
}
e.debugf("restoreHDDData: subdir=%s tarSize=%s", subdir, humanizeBytes(tarInfo.Size()))
@@ -670,7 +680,52 @@ func resolveHDDMounts(composePath string, env map[string]string) []string {
return mounts
}
// restoreVolumeData recreates Docker named volumes from bundle tarballs.
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
func validateBundleData(tmpDir string, manifest *Manifest) error {
for _, v := range manifest.VolumeNames {
if err := ValidateSegment("volume_name", v); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q kötet adata hiányzik a csomagból", v)
}
}
for _, s := range manifest.HDDSubdirs {
if err := ValidateSegment("hdd_subdir", s); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q adatkönyvtár tartalma hiányzik a csomagból", s)
}
}
return nil
}
// importVolumeTar streams tarPath into the (existing) volume via `docker cp - <cid>:/vol` —
// zero shared paths, correct on bare metal AND under the containerized controller (the
// docker-run -v population was the v0.124.0 strand's import half).
func (e *Exporter) importVolumeTar(volName, tarPath string) error {
return e.withVolumeHelper(volName, func(cid string) error {
f, err := os.Open(tarPath)
if err != nil {
return fmt.Errorf("opening %s: %w", tarPath, err)
}
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if stderr, err := dockerExec(ctx, f, nil, "cp", "-", cid+":/vol"); err != nil {
return fmt.Errorf("streaming into volume %s: %s — %w", volName, stderr, err)
}
return nil
})
}
// restoreVolumeData recreates Docker named volumes from bundle tarballs. v0.125.0: a missing
// tar is an ERROR (defense in depth behind validateBundleData), and population streams via
// docker cp instead of a docker-run -v host mount.
func (e *Exporter) restoreVolumeData(tmpDir string, manifest *Manifest) error {
volDir := filepath.Join(tmpDir, "data", "volumes")
@@ -683,34 +738,24 @@ func (e *Exporter) restoreVolumeData(tmpDir string, manifest *Manifest) error {
tarPath := filepath.Join(volDir, volName+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
e.logger.Printf("[WARN] Import: volume tar not found: %s", tarPath)
e.debugf("restoreVolumeData: tar not found: %s", tarPath)
continue
return fmt.Errorf("volume tar missing from bundle: %s", volName+".tar")
}
e.debugf("restoreVolumeData: volume=%s tarSize=%s", volName, humanizeBytes(tarInfo.Size()))
// Create the Docker volume
e.debugf("restoreVolumeData: creating docker volume %s", volName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
out, err := exec.CommandContext(ctx, "docker", "volume", "create", volName).CombinedOutput()
stderr, err := dockerExec(ctx, nil, nil, "volume", "create", volName)
cancel()
if err != nil {
return fmt.Errorf("creating volume %s: %s — %w", volName, strings.TrimSpace(string(out)), err)
return fmt.Errorf("creating volume %s: %s — %w", volName, stderr, err)
}
e.debugf("restoreVolumeData: volume %s created: %s", volName, strings.TrimSpace(string(out)))
// Populate volume from tar
// Populate volume from tar (docker cp streaming)
e.logger.Printf("[INFO] Import: populating volume %s", volName)
e.debugf("restoreVolumeData: populating %s via docker run alpine tar xf...", volName)
popStart := time.Now()
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
out, err = exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol",
"-v", volDir+":/in:ro",
"alpine", "tar", "xf", "/in/"+volName+".tar", "-C", "/vol").CombinedOutput()
cancel()
if err != nil {
return fmt.Errorf("populating volume %s: %s — %w", volName, strings.TrimSpace(string(out)), err)
if err := e.importVolumeTar(volName, tarPath); err != nil {
return fmt.Errorf("populating volume %s: %w", volName, err)
}
e.debugf("restoreVolumeData: volume %s populated in %v", volName, time.Since(popStart))
}
@@ -22,7 +22,11 @@ type rtProvider struct {
stackDir string
stacksDir string
deployed bool
running bool
volumes []string
started bool
stopped int
removed int
savedEnv map[string]string
}
@@ -32,17 +36,17 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) {
}
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
func (p *rtProvider) IsStackRunning(string) bool { return false }
func (p *rtProvider) StopStack(string) error { return nil }
func (p *rtProvider) IsStackRunning(string) bool { return p.running }
func (p *rtProvider) StopStack(string) error { p.stopped++; return nil }
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }
func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n }
func (p *rtProvider) GetStackNeedsHDD(string) bool { return false }
func (p *rtProvider) GetDockerVolumes(string) []string { return nil }
func (p *rtProvider) GetDockerVolumes(string) []string { return p.volumes }
func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed }
func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil }
func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir }
func (p *rtProvider) RefreshStacks() error { return nil }
func (p *rtProvider) RemoveStackVolumes(string) error { return nil }
func (p *rtProvider) RemoveStackVolumes(string) error { p.removed++; return nil }
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
p.savedEnv = env
return nil
@@ -0,0 +1,234 @@
package appexport
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// v0.125.0 — the containerized-.fab volume-strand fix (IA finding 1, HIGH). These tests pin:
// (B) export can no longer lie — a stranded/empty tar aborts the export, no bundle;
// (C) import validates BEFORE it destroys — a hollow bundle is refused with the app untouched;
// the docker-cp command construction (image, mount shape, cp direction) and the always-remove
// helper-container rule. The real docker legs are covered by the §3 live probe + §13 round-trip.
// dockerCall records one dockerExec invocation.
type dockerCall struct {
args []string
stdin bool
}
// swapDockerExec installs a scripted fake for the package seam and restores it on cleanup.
func swapDockerExec(t *testing.T, fn func(call dockerCall, stdin io.Reader, stdout io.Writer) (string, error)) *[]dockerCall {
t.Helper()
var mu sync.Mutex
calls := &[]dockerCall{}
orig := dockerExec
dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
mu.Lock()
c := dockerCall{args: append([]string{}, args...), stdin: stdin != nil}
*calls = append(*calls, c)
mu.Unlock()
return fn(c, stdin, stdout)
}
t.Cleanup(func() { dockerExec = orig })
return calls
}
func volTestExporter(t *testing.T, volumes []string) (*Exporter, *rtProvider, string) {
t.Helper()
srcStack := t.TempDir()
os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n vol-app:\n image: alpine\n"), 0644)
prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: volumes}
drive := t.TempDir()
return NewExporter(prov, log.New(io.Discard, "", 0), "test"), prov, drive
}
// Scenario B: a volume tar that fails to materialize (cp "succeeds" but writes nothing — the
// strand's signature) must FAIL the export with the volume named, and NO bundle may exist.
// Red-proof: remove the assertBundleDataComplete call → this test fails (hollow success).
func TestExport_HollowVolumeTarAbortsExport(t *testing.T) {
swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
switch c.args[0] {
case "create":
fmt.Fprint(stdout, "cid-123\n")
return "", nil
case "cp":
return "", nil // writes NOTHING to stdout — the stranded-tar signature
case "rm":
return "", nil
}
return "", fmt.Errorf("unexpected docker call: %v", c.args)
})
e, _, drive := volTestExporter(t, []string{"vol1"})
if err := e.StartExport(ExportRequest{StackName: "vol-app", DestDrive: drive}); err != nil {
t.Fatalf("StartExport: %v", err)
}
job := waitJob(t, e)
msg := jobErr(job)
if msg == "" {
t.Fatal("a hollow volume tar must FAIL the export — got success")
}
if !strings.Contains(msg, "vol1") {
t.Errorf("the error must NAME the missing volume, got %q", msg)
}
entries, _ := os.ReadDir(ExportDir(drive))
for _, en := range entries {
if strings.HasSuffix(en.Name(), ".fab") {
t.Fatalf("a bundle was produced despite the hollow tar: %s", en.Name())
}
}
}
// Scenario C: a bundle whose manifest claims a volume without its tar is refused BEFORE any
// destructive step — the app is not stopped, no volume is removed or recreated, zero docker
// calls happen. Red-proof: disable the pre-flight (pre-fix order: wipe first, discover later)
// → the zero-destruction assertions fail.
func TestImport_HollowBundleRefusedBeforeDestroy(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
return "", nil
})
// Handcraft a hollow bundle: manifest CLAIMS volume data, data/volumes is empty.
tree := t.TempDir()
os.MkdirAll(filepath.Join(tree, "config"), 0755)
os.MkdirAll(filepath.Join(tree, "data", "volumes"), 0755)
os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\n"), 0644)
man := &Manifest{
Version: ManifestVersion, AppName: "vol-app", DisplayName: "Vol App",
HasVolumeData: true, VolumeNames: []string{"vol1"},
ConfigFiles: []string{"docker-compose.yml"},
}
data, err := man.Marshal()
if err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(tree, "manifest.json"), data, 0644)
fab := filepath.Join(t.TempDir(), "hollow.fab")
if err := createTarGz(fab, tree); err != nil {
t.Fatalf("createTarGz: %v", err)
}
prov := &rtProvider{stackDir: t.TempDir(), stacksDir: t.TempDir(), deployed: true, running: true}
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
t.Fatalf("StartImport: %v", err)
}
job := waitJob(t, e)
msg := jobErr(job)
if msg == "" {
t.Fatal("a hollow bundle must be REFUSED — got success")
}
if !strings.Contains(msg, "érintetlen") {
t.Errorf("refusal copy must state the app is untouched, got %q", msg)
}
// THE exact non-effects: nothing was stopped, wiped, recreated or started.
if prov.stopped != 0 || prov.removed != 0 {
t.Fatalf("refusal happened AFTER destruction: stopped=%d removedVolumes=%d", prov.stopped, prov.removed)
}
if prov.started {
t.Fatal("a refused import must not start the app")
}
if len(*calls) != 0 {
t.Fatalf("a refused import must make ZERO docker calls, got %v", *calls)
}
}
// Command construction + helper hygiene: the export leg uses create/cp/rm with the exact arg
// shapes the §3 probe validated, and the helper container is force-removed EVEN when cp fails.
func TestExportVolumeTar_CommandShapesAndHelperCleanup(t *testing.T) {
t.Run("happy path shapes", func(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
switch c.args[0] {
case "create":
fmt.Fprint(stdout, "cid-abc\n")
case "cp":
fmt.Fprint(stdout, "TARBYTES")
}
return "", nil
})
e, _, _ := volTestExporter(t, nil)
tarPath := filepath.Join(t.TempDir(), "v.tar")
if err := e.exportVolumeTar("vol1", tarPath); err != nil {
t.Fatalf("exportVolumeTar: %v", err)
}
got, _ := os.ReadFile(tarPath)
if string(got) != "TARBYTES" {
t.Fatalf("tar content = %q", got)
}
want := [][]string{
{"create", "-v", "vol1:/vol", "alpine", "true"},
{"cp", "cid-abc:/vol/.", "-"},
{"rm", "-f", "cid-abc"},
}
assertCalls(t, *calls, want)
})
t.Run("helper removed on cp failure", func(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
switch c.args[0] {
case "create":
fmt.Fprint(stdout, "cid-err\n")
return "", nil
case "cp":
return "boom", fmt.Errorf("cp failed")
}
return "", nil
})
e, _, _ := volTestExporter(t, nil)
tarPath := filepath.Join(t.TempDir(), "v.tar")
if err := e.exportVolumeTar("vol1", tarPath); err == nil {
t.Fatal("cp failure must surface")
}
if _, err := os.Stat(tarPath); !os.IsNotExist(err) {
t.Error("a failed export must not leave a partial tar")
}
last := (*calls)[len(*calls)-1]
if strings.Join(last.args, " ") != "rm -f cid-err" {
t.Fatalf("helper container must be force-removed on failure, last call: %v", last.args)
}
})
t.Run("import leg shapes", func(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
if c.args[0] == "create" {
fmt.Fprint(stdout, "cid-imp\n")
}
if c.args[0] == "cp" && !c.stdin {
t.Error("import cp must stream the tar on stdin")
}
return "", nil
})
e, _, _ := volTestExporter(t, nil)
tarPath := filepath.Join(t.TempDir(), "v.tar")
os.WriteFile(tarPath, []byte("TAR"), 0644)
if err := e.importVolumeTar("vol1", tarPath); err != nil {
t.Fatalf("importVolumeTar: %v", err)
}
want := [][]string{
{"create", "-v", "vol1:/vol", "alpine", "true"},
{"cp", "-", "cid-imp:/vol"},
{"rm", "-f", "cid-imp"},
}
assertCalls(t, *calls, want)
})
}
func assertCalls(t *testing.T, got []dockerCall, want [][]string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("docker calls = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if strings.Join(got[i].args, " ") != strings.Join(want[i], " ") {
t.Errorf("call %d = %v, want %v", i, got[i].args, want[i])
}
}
}