package appexport import ( "archive/tar" "compress/gzip" "fmt" "io" "log" "os" "path/filepath" "strings" "testing" ) // C6B-F1 (v0.130.0) — the additive-export tests. A needs_hdd app bundles BOTH its HDD mounts // and its named volumes (the old either/or dropped every needs_hdd app's volumes); a basename // collision between mounts fails LOUDLY (the old code silently overwrote the first tar); the // HDD round-trip places a "userdata" tar back at /userdata through the untouched // import mapping. // listFabEntries returns the entry names inside an unencrypted .fab (tar.gz). func listFabEntries(t *testing.T, fabPath string) map[string]int64 { t.Helper() f, err := os.Open(fabPath) if err != nil { t.Fatal(err) } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { t.Fatal(err) } tr := tar.NewReader(gz) entries := map[string]int64{} for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } entries[filepath.ToSlash(hdr.Name)] = hdr.Size } return entries } func findFab(t *testing.T, drive string) string { t.Helper() entries, _ := os.ReadDir(ExportDir(drive)) for _, en := range entries { if strings.HasSuffix(en.Name(), ".fab") { return filepath.Join(ExportDir(drive), en.Name()) } } t.Fatal("no .fab produced") return "" } // Scenario A (§7): the sonarr shape — a needs_hdd app with a populated userdata mount AND a // named volume. The bundle must contain BOTH tars; the manifest must claim both. // RED-PROOF (either/or): revert executeExport to the else-only volume branch → the volume tar // is absent and has_volume_data=false → this test fails. // RED-PROOF (discovery): with the pre-fix ${HDD_PATH}-only adapter the mount list is empty → // has_hdd_data=false → this test fails (proven at the adapter level in stacks/export_mounts_test.go). func TestExport_NeedsHDDBundlesBothUserdataAndVolumes(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": // stream a plausible non-empty tar for the volume fmt.Fprint(stdout, strings.Repeat("VOLTAR", 100)) return "", nil case "rm": return "", nil } return "", fmt.Errorf("unexpected docker call: %v", c.args) }) srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n hdd-app:\n image: alpine\n"), 0644) hdd := t.TempDir() ud := filepath.Join(hdd, "userdata") os.MkdirAll(filepath.Join(ud, "media", "tv"), 0755) os.WriteFile(filepath.Join(ud, "media", "tv", "marker.bin"), []byte("USERDATA-MARKER-7"), 0644) prov := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: []string{"hdd-app_config"}}, mounts: []string{ud}, hddPath: hdd, } drive := t.TempDir() e := NewExporter(prov, log.New(io.Discard, "", 0), "test") if err := e.StartExport(ExportRequest{StackName: "hdd-app", DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) if msg := jobErr(job); msg != "" { t.Fatalf("export failed: %s", msg) } fabPath := findFab(t, drive) man, err := ReadManifestFromFAB(fabPath) if err != nil { t.Fatalf("manifest: %v", err) } if !man.HasHDDData { t.Error("has_hdd_data=false — the userdata mount was dropped (C6B-F1 cause 2)") } if !man.HasVolumeData { t.Error("has_volume_data=false — the named volume was dropped (C6B-F1 cause 1, the either/or)") } entries := listFabEntries(t, fabPath) if sz, ok := entries["data/hdd/userdata.tar"]; !ok || sz == 0 { t.Errorf("bundle is missing a non-empty data/hdd/userdata.tar (entries: %v)", entries) } if sz, ok := entries["data/volumes/hdd-app_config.tar"]; !ok || sz == 0 { t.Errorf("bundle is missing a non-empty data/volumes/hdd-app_config.tar (entries: %v)", entries) } } // Scenario E (§7): a needs_hdd app whose volume export strands (cp writes nothing) must FAIL the // whole job loudly — never a partial-success bundle with userdata but silently-missing volumes. func TestExport_NeedsHDDVolumeStrandFailsLoud(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 // stranded: nothing written case "rm": return "", nil } return "", fmt.Errorf("unexpected docker call: %v", c.args) }) srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n hdd-app:\n image: alpine\n"), 0644) hdd := t.TempDir() ud := filepath.Join(hdd, "userdata") os.MkdirAll(ud, 0755) os.WriteFile(filepath.Join(ud, "f.bin"), []byte("x"), 0644) prov := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: []string{"vol1"}}, mounts: []string{ud}, hddPath: hdd, } drive := t.TempDir() e := NewExporter(prov, log.New(io.Discard, "", 0), "test") if err := e.StartExport(ExportRequest{StackName: "hdd-app", DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) if msg := jobErr(job); msg == "" { t.Fatal("a stranded volume tar must FAIL a needs_hdd export too — got success") } entries, _ := os.ReadDir(ExportDir(drive)) for _, en := range entries { if strings.HasSuffix(en.Name(), ".fab") { t.Fatalf("a bundle was produced despite the stranded volume: %s", en.Name()) } } } // §8: two mounts sharing a basename cannot round-trip through the basename-keyed manifest — // the export must fail loudly instead of silently overwriting the first tar (the pre-fix // behavior). RED-PROOF: drop the collision check in exportHDDData → this test fails. func TestExport_HDDMountBasenameCollisionFailsLoud(t *testing.T) { srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n hdd-app:\n image: alpine\n"), 0644) hdd := t.TempDir() a := filepath.Join(hdd, "a", "config") b := filepath.Join(hdd, "b", "config") os.MkdirAll(a, 0755) os.MkdirAll(b, 0755) os.WriteFile(filepath.Join(a, "one.txt"), []byte("A"), 0644) os.WriteFile(filepath.Join(b, "two.txt"), []byte("B"), 0644) prov := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true}, mounts: []string{a, b}, hddPath: hdd, } drive := t.TempDir() e := NewExporter(prov, log.New(io.Discard, "", 0), "test") if err := e.StartExport(ExportRequest{StackName: "hdd-app", DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) msg := jobErr(job) if msg == "" { t.Fatal("a basename collision must FAIL the export — got success (silent overwrite)") } if !strings.Contains(msg, "config") { t.Errorf("the error must name the colliding basename, got %q", msg) } } // Scenario A' — the round-trip placement proof: an exported "userdata" tar restores back to // /userdata through the UNTOUCHED import mapping (basename → / // fallback). This is the property that dictated capturing the userdata ROOT rather than // per-bind subpaths. func TestFabRoundTrip_UserdataPlacement(t *testing.T) { const stack = "ud-app" lg := log.New(io.Discard, "", 0) hdd := t.TempDir() ud := filepath.Join(hdd, "userdata") os.MkdirAll(filepath.Join(ud, "media", "tv"), 0755) marker := "ROUNDTRIP-MARKER-99" os.WriteFile(filepath.Join(ud, "media", "tv", "show.bin"), []byte(marker), 0644) srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n ud-app:\n image: alpine\n volumes:\n - ${USERDATA_PATH}/media/tv:/tv\n"), 0644) // app.yaml carries HDD_PATH into the bundle — the import derives every restore path from it. os.WriteFile(filepath.Join(srcStack, "app.yaml"), []byte("deployed: true\nenv:\n HDD_PATH: "+hdd+"\n"), 0644) prov := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true}, mounts: []string{ud}, hddPath: hdd, } drive := t.TempDir() e := NewExporter(prov, lg, "test") if err := e.StartExport(ExportRequest{StackName: stack, DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) if msg := jobErr(job); msg != "" { t.Fatalf("export failed: %s", msg) } fabPath := findFab(t, drive) // wipe the source userdata — the import must bring it back to the same place if err := os.RemoveAll(ud); err != nil { t.Fatal(err) } prov2 := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false}, hddPath: hdd, } e2 := NewExporter(prov2, lg, "test") if err := e2.StartImport(ImportRequest{FABPath: fabPath}); err != nil { t.Fatalf("StartImport: %v", err) } job = waitJob(t, e2) if msg := jobErr(job); msg != "" { t.Fatalf("import failed: %s", msg) } got, err := os.ReadFile(filepath.Join(hdd, "userdata", "media", "tv", "show.bin")) if err != nil { t.Fatalf("restored userdata not at /userdata/media/tv/show.bin: %v", err) } if string(got) != marker { t.Fatalf("restored content differs: got %q want %q", got, marker) } } // Scenario D (§7) — the anti-hollow net (C6B-F1 cause 3): a needs_hdd app where discovery finds // NOTHING (mounts absent on disk, no volumes) must FAIL the export with an honest Hungarian // error — never success-with-a-hollow-bundle. RED-PROOF: remove the needs_hdd&&no-data assertion // from assertBundleDataComplete → this test fails (the pre-fix silent hollow success). func TestExport_NeedsHDDNoDataAtAllRefused(t *testing.T) { srcStack := t.TempDir() os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n hdd-app:\n image: alpine\n"), 0644) hdd := t.TempDir() prov := &hddProvider{ rtProvider: &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true}, // the mount does NOT exist on disk and there are no volumes — total discovery blank mounts: []string{filepath.Join(hdd, "userdata")}, hddPath: hdd, } drive := t.TempDir() e := NewExporter(prov, log.New(io.Discard, "", 0), "test") if err := e.StartExport(ExportRequest{StackName: "hdd-app", DestDrive: drive}); err != nil { t.Fatalf("StartExport: %v", err) } job := waitJob(t, e) msg := jobErr(job) if msg == "" { t.Fatal("a needs_hdd export with ZERO discovered data must FAIL — got success (the hollow bundle)") } if !strings.Contains(msg, "nem tartalmaz alkalmazásadatot") { t.Errorf("expected the honest Hungarian no-app-data error, got %q", msg) } entries, _ := os.ReadDir(ExportDir(drive)) for _, en := range entries { if strings.HasSuffix(en.Name(), ".fab") { t.Fatalf("a hollow bundle was produced: %s", en.Name()) } } }