diff --git a/controller/internal/appexport/export.go b/controller/internal/appexport/export.go index e3b6da6..a378069 100644 --- a/controller/internal/appexport/export.go +++ b/controller/internal/appexport/export.go @@ -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 ` 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 :/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. diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index 9866274..7fc2af3 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -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 - :/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)) } diff --git a/controller/internal/appexport/roundtrip_test.go b/controller/internal/appexport/roundtrip_test.go index e86fb45..f747953 100644 --- a/controller/internal/appexport/roundtrip_test.go +++ b/controller/internal/appexport/roundtrip_test.go @@ -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 diff --git a/controller/internal/appexport/volume_guard_test.go b/controller/internal/appexport/volume_guard_test.go new file mode 100644 index 0000000..6f97c49 --- /dev/null +++ b/controller/internal/appexport/volume_guard_test.go @@ -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]) + } + } +} diff --git a/controller/scripts/docker_run_volume_path_gate.py b/controller/scripts/docker_run_volume_path_gate.py new file mode 100644 index 0000000..02a6a48 --- /dev/null +++ b/controller/scripts/docker_run_volume_path_gate.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""docker-run volume-path gate (v0.125.0, scenario D) — the class behind the v0.124.0 HIGH +finding: a `docker … -v ` mount whose host side is a CONTROLLER-LOCAL path (os.MkdirTemp +etc.) resolves against the GUEST filesystem when the controller runs containerized, silently +stranding data. Every `"-v"` argument in non-test Go code must be on the explicit allowlist +below; anything new fails the gate until it is reviewed and either rewritten (docker cp +streaming — appexport's pattern) or proven host-visible and allowlisted WITH ITS WHY. + +Run from controller/: python scripts/docker_run_volume_path_gate.py +""" +import io, os, re, sys + +ROOTS = ["internal", "cmd"] + +# (file suffix, substring that must appear on the "-v" line, why it is safe) +ALLOWLIST = [ + ("internal/appbackup/dbdump.go", '"psql", "-v"', + "psql's own -v flag (ON_ERROR_STOP) — not a docker mount at all"), + ("internal/appexport/export.go", '"create", "-v", volName+":/vol"', + "named-volume mount (no host path): docker resolves volume names daemon-side; the tar " + "itself streams via docker cp (v0.125.0)"), + ("internal/backup/backup.go", '"-v", volName+":/vol:ro"', + "Tier-1 volume dump, named-volume source — daemon-side, no host path"), + ("internal/backup/backup.go", '"-v", dumpDir+":/out"', + "Tier-1 volume dump target: dumpDir is ALWAYS a registered-drive namespace path " + "(/mnt/** or /opt/docker/** — the golden deployment bind-mounts these into the " + "controller container at IDENTICAL paths, so the daemon resolves them correctly; " + "verified by container-inspect 2026-07-13)"), + ("internal/backup/restore.go", '"-v", volName+":/vol"', + "Tier-1 volume restore, named-volume dest — daemon-side"), + ("internal/backup/restore.go", '"-v", dumpDir+":/in:ro"', + "Tier-1 volume restore source: same registered-drive namespace argument as the dump " + "target above — host-visible by the identical binds"), + ("internal/web/handlers.go", '"compose", "down", "-v"', + "docker compose's own --volumes flag (DR reset wipes the stack's volumes) — not a mount"), +] + +VLINE = re.compile(r'"-v"') + + +def allowed(path, line): + p = path.replace("\\", "/") + for suffix, marker, _why in ALLOWLIST: + if p.endswith(suffix) and marker in line: + return True + return False + + +def main(): + hits = 0 + for root in ROOTS: + for dirpath, _dirs, files in os.walk(root): + for fn in files: + if not fn.endswith(".go") or fn.endswith("_test.go"): + continue + path = os.path.join(dirpath, fn) + for lineno, line in enumerate(io.open(path, encoding="utf-8"), 1): + if VLINE.search(line) and not allowed(path, line): + hits += 1 + print("%s:%d %s" % (path, lineno, + line.strip()[:100].encode("ascii", "backslashreplace").decode())) + if hits: + print("DOCKER -v GATE FAILED: %d unreviewed '-v' argument(s) — rewrite as docker cp " + "streaming or allowlist with a WHY" % hits) + sys.exit(1) + print("docker -v gate OK — every volume mount is named-volume or proven host-visible") + + +if __name__ == "__main__": + main()