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
@@ -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])
}
}
}