package migrate import ( "archive/tar" "bytes" "compress/gzip" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/arescom/dockmv/internal/spec" ) func buildPrepared(t *testing.T, c *spec.Container, vols []spec.Volume, nets []spec.Network) *Prepared { t.Helper() sel := spec.DefaultSelection(c) sel.Include = true p, err := Prepare(c, sel, vols, nets) if err != nil { t.Fatal(err) } return p } func fixture(t *testing.T) ([]*Prepared, *spec.Manifest, map[string]string) { t.Helper() c := &spec.Container{ ID: "id1", Name: "shop-db", State: "running", Image: "postgres:16", Env: []string{"POSTGRES_PASSWORD=p'w\"d $(whoami)"}, Labels: map[string]string{"note": "a;b`c`"}, Mounts: []spec.Mount{ {Kind: spec.MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"}, {Kind: spec.MountBind, Source: "/srv/shop/initdb", Destination: "/docker-entrypoint-initdb.d", ReadOnly: true}, }, Endpoints: []spec.Endpoint{{Network: "shopnet"}}, Ports: []spec.PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}}, } p := buildPrepared(t, c, []spec.Volume{{Name: "pgdata", Driver: "local"}}, []spec.Network{{Name: "shopnet", Driver: "bridge"}}, ) man := &spec.Manifest{ FormatVersion: 1, CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), SourceHost: "old-host", DockerVersion: "27.0.0", Options: spec.DefaultOptions(), Payloads: []spec.Payload{ {Path: "images/postgres_16.tar.gz", Kind: "image", Image: "postgres:16", SHA256: "aa", Compressed: true}, {Path: "data/shop-db/00-var_lib_postgresql_data.tar.gz", Kind: "mount", Container: "shop-db", Destination: "/var/lib/postgresql/data", SHA256: "bb", Compressed: true}, {Path: "data/shop-db/01-docker-entrypoint-initdb.d.tar.gz", Kind: "mount", Container: "shop-db", Destination: "/docker-entrypoint-initdb.d", SHA256: "cc", Compressed: true}, }, } return []*Prepared{p}, man, map[string]string{"postgres:16": "images/postgres_16.tar.gz"} } func TestInstallerIsValidBash(t *testing.T) { bash, err := exec.LookPath("bash") if err != nil { t.Skip("bash is not available on this machine") } prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") path := filepath.Join(t.TempDir(), "install.sh") if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatal(err) } out, err := exec.Command(bash, "-n", path).CombinedOutput() if err != nil { t.Fatalf("generated installer is not valid bash: %v\n%s\n---\n%s", err, out, numbered(script)) } } // TestInstallerRunsCleanlyInDryRun executes the generated script against a // stub docker, which is the closest thing to a real run that does not need a // docker daemon. func TestInstallerDryRunExecutes(t *testing.T) { bash, err := exec.LookPath("bash") if err != nil { t.Skip("bash is not available on this machine") } prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil { t.Fatal(err) } // The installer checks that every payload is present even on a dry run, so // that an incomplete package is reported before anything is changed. writePayloads(t, dir, man) binDir := writeStubDocker(t, dir, false) env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) run := func(args ...string) (string, error) { cmd := exec.Command(bash, append([]string{"./install.sh"}, args...)...) cmd.Dir = dir cmd.Env = env out, err := cmd.CombinedOutput() return string(out), err } // The payload contents here are placeholders, so checksums are skipped; // the real checksums are exercised by the end-to-end test. text, err := run("--dry-run", "--yes", "--skip-verify") if err != nil { t.Fatalf("dry run failed: %v\n%s", err, text) } for _, want := range []string{"shop-db", "would run", "migration complete", "creating network shopnet"} { if !strings.Contains(text, want) { t.Errorf("dry run output missing %q:\n%s", want, text) } } // A package whose payload does not match its checksum must be refused, // rather than restoring truncated data. corrupt, err := run("--dry-run", "--yes") if err == nil { t.Errorf("a payload with a bad checksum was accepted:\n%s", corrupt) } else if !strings.Contains(corrupt, "checksum mismatch") { t.Errorf("expected a checksum mismatch error, got:\n%s", corrupt) } } // TestInstallerReportsFailedStart guards against the worst failure mode there // is: reporting a successful migration when the container never started. // // The per-container work runs inside a function invoked from an `if !` test, // which disables `set -e` for that whole function body, so every command has to // be checked explicitly or its failure is silently discarded. func TestInstallerReportsFailedStart(t *testing.T) { bash, err := exec.LookPath("bash") if err != nil { t.Skip("bash is not available on this machine") } prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil { t.Fatal(err) } writePayloads(t, dir, man) binDir := writeStubDocker(t, dir, true) cmd := exec.Command(bash, "./install.sh", "--yes", "--skip-verify") cmd.Dir = dir cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) out, err := cmd.CombinedOutput() text := string(out) if err == nil { t.Fatalf("the installer exited 0 even though the container never started:\n%s", text) } if strings.Contains(text, "migration complete") { t.Errorf("the installer claimed the migration completed:\n%s", text) } for _, want := range []string{"could not start", "container(s) failed"} { if !strings.Contains(text, want) { t.Errorf("expected the output to contain %q:\n%s", want, text) } } } // TestInstallerQuotesHostileValues makes sure values taken from container // metadata cannot break out of the generated script. func TestInstallerQuotesHostileValues(t *testing.T) { prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") // The password contains a quote, a double quote and a command // substitution; none of it may appear unquoted. if strings.Contains(script, "POSTGRES_PASSWORD=p'w\"d $(whoami)") { t.Error("environment value was interpolated without quoting") } if !strings.Contains(script, `'POSTGRES_PASSWORD=p'\''w"d $(whoami)'`) { t.Errorf("environment value is not quoted as expected:\n%s", grepLines(script, "POSTGRES_PASSWORD")) } } func TestInstallerHandlesReadOnlyMountThroughStaging(t *testing.T) { prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") if !strings.Contains(script, "seed_readonly") { t.Error("read-only mount must be seeded through a staging container") } // The writable volume is fed into the real container directly. Shell-safe // paths are emitted without quotes, which is what ShellQuote does. want := `feed_archive data/shop-db/00-var_lib_postgresql_data.tar.gz 1 "$CNAME" /var/lib/postgresql` if !strings.Contains(script, want) { t.Errorf("writable volume restore command is wrong:\nwant a line containing: %s\ngot:\n%s", want, grepLines(script, "feed_archive")) } // Restoring must target the parent directory, never the mount point itself, // because the archive entries are already rooted at the last segment. if strings.Contains(script, `"$CNAME" /var/lib/postgresql/data`) { t.Error("archive is being extracted into the mount point instead of its parent") } } func TestInstallerVerifiesChecksums(t *testing.T) { prepared, man, images := fixture(t) script := renderInstaller(prepared, man, images, "shop-migration") // Every payload in the manifest must be checksummed before it is fed to // docker, so a truncated package fails loudly instead of restoring garbage. for _, p := range man.Payloads { var want string if p.Kind == "image" { want = "ensure_image_load " + spec.ShellQuote(p.Image) + " " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256) } else { want = "verify_payload " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256) } if !strings.Contains(script, want) { t.Errorf("payload %s is not verified\nwant a line containing: %s\ngot:\n%s", p.Path, want, grepLines(script, "verify_payload")) } } } func numbered(s string) string { var b strings.Builder for i, line := range strings.Split(s, "\n") { b.WriteString(strings.TrimRight(line, "\r")) b.WriteByte('\n') if i > 200 { b.WriteString("...\n") break } } return b.String() } func grepLines(s, needle string) string { var out []string for _, l := range strings.Split(s, "\n") { if strings.Contains(l, needle) { out = append(out, l) } } return strings.Join(out, "\n") } // writePayloads materialises every payload the manifest references as a real // gzipped tar, so the generated installer's gzip and docker cp steps behave the // way they would with a genuine package. func writePayloads(t *testing.T, dir string, man *spec.Manifest) { t.Helper() for _, p := range man.Payloads { full := filepath.Join(dir, filepath.FromSlash(p.Path)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatal(err) } var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) body := []byte("payload for " + p.Path + "\n") if err := tw.WriteHeader(&tar.Header{ Name: "placeholder.txt", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg, }); err != nil { t.Fatal(err) } if _, err := tw.Write(body); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil { t.Fatal(err) } } } // writeStubDocker installs a fake docker CLI on PATH and returns its directory. // // It models just enough of the real thing for the installer to run without a // daemon: nothing exists yet except the image, and `docker inspect --format` // answers the mount lookup the read-only seeding path depends on. When // failStart is set, `docker start` fails the way it does on a target whose // published port is already taken. func writeStubDocker(t *testing.T, dir string, failStart bool) string { t.Helper() startCase := "" if failStart { startCase = ` start) echo "Bind for 0.0.0.0:5432 failed: port is already allocated" >&2; exit 1 ;;` + "\n" } stub := `#!/usr/bin/env bash # object existence probes: "docker inspect " case "$1 $2" in "image inspect") exit 0 ;; "container inspect"|"volume inspect"|"network inspect") exit 1 ;; esac case "$1" in version) echo 27.0.0 ;; # resolve_mount calls "docker inspect --format " inspect) echo "/docker-entrypoint-initdb.d|stub-volume|" ;; ` + startCase + `esac exit 0 ` binDir := filepath.Join(dir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(stub), 0o755); err != nil { t.Fatal(err) } return binDir }