466f42708e
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
235 lines
8.1 KiB
Go
235 lines
8.1 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|