Initial push
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user