Files
kawaandClaude Sonnet 5 34e18987a0 Rename product to dockmv (container, image, module, env vars)
Container/image/service name, Go module path, CLI binary name, and
DOCKER_MIGRATE_* env vars still used the old working name; the project
is branded DockMV everywhere else (README, logo, Gitea repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:56:26 +02:00

324 lines
11 KiB
Go

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 <kind> inspect <name>"
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 <tmpl> <container>"
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
}