Files
DockMV/test/e2e_test.go
T
2026-08-11 09:00:01 +02:00

310 lines
8.9 KiB
Go

//go:build e2e
// Package e2e exercises the full pipeline against a real Docker daemon:
// inventory -> plan -> package build -> generated installer -> restored
// container, and then checks that the data actually arrived.
//
// It needs a Linux host with a local Docker daemon and bash. Run it with:
//
// go test -tags e2e ./test/... -v
package e2e
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/migrate"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
)
const (
testImage = "alpine:3.20"
srcName = "dmtest-src"
volName = "dmtest-vol"
restoreSufix = "-restored"
)
func TestPackageRoundTrip(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash is required to run the generated installer")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("the docker CLI is required by the generated installer")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
c, err := dkr.New("")
if err != nil {
t.Skipf("no docker daemon available: %v", err)
}
defer c.Close()
if _, err := c.Ping(ctx); err != nil {
t.Skipf("no docker daemon available: %v", err)
}
bindDir := t.TempDir()
if err := os.WriteFile(filepath.Join(bindDir, "app.conf"), []byte("mode=production\n"), 0o644); err != nil {
t.Fatal(err)
}
cleanup(ctx, t, c, srcName, srcName+restoreSufix)
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer ccancel()
cleanup(cctx, t, c, srcName, srcName+restoreSufix)
})
pullImage(ctx, t, c, testImage)
createSource(ctx, t, c, bindDir)
// --- inventory -------------------------------------------------------
inv, err := c.Inventory(ctx)
if err != nil {
t.Fatal(err)
}
var src *spec.Container
for i := range inv.Containers {
if inv.Containers[i].Name == srcName {
src = &inv.Containers[i]
}
}
if src == nil {
t.Fatalf("source container %s not found in the inventory", srcName)
}
t.Logf("source: %s state=%s image=%s mounts=%d", src.Name, src.State, src.Image, len(src.Mounts))
wantMounts := map[string]spec.MountKind{
"/data": spec.MountVolume,
"/conf": spec.MountBind,
"/anon": spec.MountAnonymous,
}
for dest, kind := range wantMounts {
found := false
for _, m := range src.Mounts {
if m.Destination == dest {
found = true
if m.Kind != kind {
t.Errorf("mount %s: kind %s, want %s", dest, m.Kind, kind)
}
}
}
if !found {
t.Errorf("mount %s missing from the inventory", dest)
}
}
// --- build the package ----------------------------------------------
sel := spec.DefaultSelection(src)
sel.Include = true
// The image is already on this daemon, so there is no point carrying it
// through a round trip that restores onto the same host.
sel.MigrateImage = true
sel.ImageMode = spec.ImageSkip
opts := spec.DefaultOptions()
opts.Conflict = spec.ConflictRename
opts.RenameSuffix = restoreSufix
outDir := t.TempDir()
packager := &migrate.Packager{
Src: c, Containers: inv.Containers, Volumes: inv.Volumes, Networks: inv.Networks,
Plan: spec.Plan{Items: []spec.ItemSelection{sel}, Options: opts, PackageName: "dmtest"},
OutputDir: outDir, Format: migrate.FormatDir, SourceHost: inv.Host,
}
jobs := job.NewManager()
var res *migrate.Result
var runErr error
j := jobs.Run(ctx, job.KindPackage, "e2e", false, func(ctx context.Context, j *job.Job) error {
res, runErr = packager.Run(ctx, j)
return runErr
})
<-j.Done()
snap := j.Snapshot()
for _, l := range snap.Log {
t.Logf("[%s] %s", l.Level, l.Message)
}
if snap.State != job.StateSucceeded {
t.Fatalf("package job %s: %s", snap.State, snap.Error)
}
t.Logf("package built at %s (%d bytes)", res.Path, res.Bytes)
for _, want := range []string{"install.sh", "manifest.json", "README.txt"} {
if _, err := os.Stat(filepath.Join(res.Path, want)); err != nil {
t.Fatalf("package is missing %s: %v", want, err)
}
}
// --- dry run first ---------------------------------------------------
if out, err := runInstaller(res.Path, "--dry-run", "--yes"); err != nil {
t.Fatalf("installer dry run failed: %v\n%s", err, out)
} else {
t.Logf("dry run ok:\n%s", indent(out))
}
if _, err := containerExists(ctx, c, srcName+restoreSufix); err == nil {
t.Fatal("dry run created a container; it must change nothing")
}
// --- real restore ----------------------------------------------------
out, err := runInstaller(res.Path, "--yes", "--conflict", "rename", "--rename-suffix", restoreSufix)
if err != nil {
t.Fatalf("installer failed: %v\n%s", err, out)
}
t.Logf("restore output:\n%s", indent(out))
restored := srcName + restoreSufix
if _, err := containerExists(ctx, c, restored); err != nil {
t.Fatalf("restored container %s does not exist: %v", restored, err)
}
// --- verify the data actually travelled ------------------------------
checks := []struct {
path, want string
}{
{"/data/hello.txt", "hello from the volume"},
{"/data/nested/deep.txt", "nested payload"},
{"/conf/app.conf", "mode=production"},
{"/anon/anon.txt", "anonymous volume payload"},
}
for _, chk := range checks {
got, err := readFileFromContainer(ctx, c, restored, chk.path)
if err != nil {
t.Errorf("reading %s from %s: %v", chk.path, restored, err)
continue
}
if !strings.Contains(got, chk.want) {
t.Errorf("%s = %q, want it to contain %q", chk.path, got, chk.want)
} else {
t.Logf("verified %s", chk.path)
}
}
// The read-only mount must still be read-only on the restored container.
insp, err := c.API().ContainerInspect(ctx, restored)
if err != nil {
t.Fatal(err)
}
for _, m := range insp.Mounts {
if m.Destination == "/conf" && m.RW {
t.Error("/conf was read-only on the source but is writable on the target")
}
}
}
func createSource(ctx context.Context, t *testing.T, c *dkr.Client, bindDir string) {
t.Helper()
api := c.API()
if _, err := api.VolumeCreate(ctx, volume.CreateOptions{Name: volName}); err != nil {
t.Fatal(err)
}
script := strings.Join([]string{
"echo 'hello from the volume' > /data/hello.txt",
"mkdir -p /data/nested",
"echo 'nested payload' > /data/nested/deep.txt",
"ln -sf hello.txt /data/link.txt",
"echo 'anonymous volume payload' > /anon/anon.txt",
"sleep 3600",
}, " && ")
resp, err := api.ContainerCreate(ctx,
&container.Config{
Image: testImage,
Cmd: []string{"sh", "-c", script},
Env: []string{"DM_TEST=1", "DM_QUOTED=a'b\"c"},
Labels: map[string]string{"dm.test": "yes"},
},
&container.HostConfig{
Mounts: []mount.Mount{
{Type: mount.TypeVolume, Source: volName, Target: "/data"},
{Type: mount.TypeBind, Source: bindDir, Target: "/conf", ReadOnly: true},
{Type: mount.TypeVolume, Target: "/anon"},
},
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped},
},
nil, nil, srcName)
if err != nil {
t.Fatal(err)
}
if err := api.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
t.Fatal(err)
}
// Give the entrypoint script time to write the files.
time.Sleep(2 * time.Second)
}
func pullImage(ctx context.Context, t *testing.T, c *dkr.Client, ref string) {
t.Helper()
if c.ImageExists(ctx, ref) {
return
}
rc, err := c.API().ImagePull(ctx, ref, image.PullOptions{})
if err != nil {
t.Fatalf("pull %s: %v", ref, err)
}
defer rc.Close()
if _, err := io.Copy(io.Discard, rc); err != nil {
t.Fatalf("pull %s: %v", ref, err)
}
}
func cleanup(ctx context.Context, t *testing.T, c *dkr.Client, names ...string) {
t.Helper()
api := c.API()
for _, n := range names {
_ = api.ContainerRemove(ctx, n, container.RemoveOptions{Force: true, RemoveVolumes: false})
}
_ = api.VolumeRemove(ctx, volName, true)
}
func containerExists(ctx context.Context, c *dkr.Client, name string) (string, error) {
j, err := c.API().ContainerInspect(ctx, name)
if err != nil {
return "", err
}
return j.ID, nil
}
// readFileFromContainer reads one file through the same archive API the
// migration itself uses, which avoids needing the container to be running.
func readFileFromContainer(ctx context.Context, c *dkr.Client, name, path string) (string, error) {
rc, err := c.CopyOut(ctx, name, path)
if err != nil {
return "", err
}
defer rc.Close()
return firstFileInTar(rc)
}
func runInstaller(dir string, args ...string) (string, error) {
cmd := exec.Command("bash", append([]string{"./install.sh"}, args...)...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
func indent(s string) string {
var b strings.Builder
for _, l := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
fmt.Fprintf(&b, " %s\n", l)
}
return b.String()
}