324 lines
11 KiB
Go
324 lines
11 KiB
Go
package migrate
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/arescom/docker-migrate/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
|
|
}
|