//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() }