diff --git a/internal/preflight/checks.go b/internal/preflight/checks.go index a228322..9558134 100644 --- a/internal/preflight/checks.go +++ b/internal/preflight/checks.go @@ -218,6 +218,15 @@ func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoi return report, err } + // The container checks below only mean anything for a container, and + // asking docker about one that isn't there would fail for the wrong + // reason. + if DeploymentKind(deploymentOutcome.Extra) == DeploymentDocker { + if err := c.runContainerChecks(ctx, runCheck); err != nil { + return report, err + } + } + storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) { matches, err := DetectStoreBackends(c.opts.ConfigPath) if err != nil { diff --git a/internal/preflight/checks_test.go b/internal/preflight/checks_test.go index 233f65f..047cbcd 100644 --- a/internal/preflight/checks_test.go +++ b/internal/preflight/checks_test.go @@ -465,12 +465,10 @@ func withFakeDocker(t *testing.T) { t.Skipf("host has %s, which detection prefers over docker", p) } } - dir := t.TempDir() - script := "#!/bin/sh\ncase \"$1\" in inspect) exit 0 ;; esac\nexit 1\n" - if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + // A healthy plain container: not compose-managed, data on a volume. + // The container checks inspect it for real now, so an `inspect` that + // merely exits 0 would fail them for the wrong reason. + fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})) } // dockerPreflight runs a minimal but real preflight against a fake 0.15.5 diff --git a/internal/preflight/container.go b/internal/preflight/container.go new file mode 100644 index 0000000..2d4a2e4 --- /dev/null +++ b/internal/preflight/container.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2026 LINUXexpert-org +// SPDX-License-Identifier: GPL-3.0-or-later + +package preflight + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint" +) + +// checkFunc is the closure Checker.Run uses to run and checkpoint one +// check. Named here so the container checks can be a method rather than +// another hundred lines inside Run. +type checkFunc func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error) + +// ComposeProjectLabel is set by Docker Compose on every container it +// manages. Its presence is the difference between a container this tool +// could one day recreate and one it must not: recreating a compose-managed +// container out from under compose leaves the running container and the +// compose file disagreeing about what is deployed, and the next +// `compose up` silently reverts the migration. +const ComposeProjectLabel = "com.docker.compose.project" + +// Mount is one bind or volume mount as the container sees it. Only the +// fields this tool reasons about are kept; docker inspect returns more. +type Mount struct { + Type string `json:"Type"` // "volume" or "bind" + Name string `json:"Name"` // volume name, empty for binds + Source string `json:"Source"` // host path + Destination string `json:"Destination"` // path inside the container + RW bool `json:"RW"` +} + +// ContainerFacts is what `docker inspect` says about a running Stalwart +// container, reduced to the things that decide whether it can be migrated. +type ContainerFacts struct { + Name string + Image string // the tag it was started from, e.g. "stalwartlabs/stalwart:v0.15.5" + ImageID string // the digest actually running, which a tag can drift from + Labels map[string]string + Mounts []Mount + Running bool +} + +// ComposeProject returns the compose project managing this container, or +// "" if it is a plain `docker run`. +func (f ContainerFacts) ComposeProject() string { return f.Labels[ComposeProjectLabel] } + +// WritableMounts are the mounts data could persist in. A container with +// none keeps everything in its own writable layer, which is discarded when +// the container is replaced - and replacing the container is exactly what +// migrating it means. +func (f ContainerFacts) WritableMounts() []Mount { + var out []Mount + for _, m := range f.Mounts { + if m.RW { + out = append(out, m) + } + } + return out +} + +// MountFor returns the mount whose Destination contains path, if any. A +// data directory not covered by one lives in the writable layer. +func (f ContainerFacts) MountFor(path string) (Mount, bool) { + if path == "" { + return Mount{}, false + } + var best Mount + var found bool + for _, m := range f.Mounts { + if m.Destination == path || strings.HasPrefix(path, strings.TrimSuffix(m.Destination, "/")+"/") { + // Longest destination wins: /var/lib/stalwart/data is more + // specific than /var/lib, and it is the specific one that + // actually holds the bytes. + if !found || len(m.Destination) > len(best.Destination) { + best, found = m, true + } + } + } + return best, found +} + +// inspectOutput is the subset of `docker inspect` this parses. Named +// separately from ContainerFacts because docker's shape is docker's to +// change, and the rest of this package should not have to know it. +type inspectOutput struct { + Name string `json:"Name"` + Image string `json:"Image"` + Config struct { + Image string `json:"Image"` + Labels map[string]string `json:"Labels"` + } `json:"Config"` + State struct { + Running bool `json:"Running"` + } `json:"State"` + Mounts []Mount `json:"Mounts"` +} + +// InspectContainer reads the facts about containerName. An error here is +// an error, not an absent container: callers reach this only after +// DetectDeploymentKind has already established that a container answers to +// this name, so a failure now means docker stopped answering, and guessing +// past that is how a tool ends up migrating something it cannot see. +func InspectContainer(ctx context.Context, containerName string) (ContainerFacts, error) { + if containerName == "" { + containerName = "stalwart" + } + out, err := exec.CommandContext(ctx, "docker", "inspect", containerName).Output() + if err != nil { + return ContainerFacts{}, fmt.Errorf("preflight: docker inspect %s: %w", containerName, err) + } + var got []inspectOutput + if err := json.Unmarshal(out, &got); err != nil { + return ContainerFacts{}, fmt.Errorf("preflight: parsing docker inspect %s: %w", containerName, err) + } + if len(got) == 0 { + return ContainerFacts{}, fmt.Errorf("preflight: docker inspect %s returned no container", containerName) + } + c := got[0] + return ContainerFacts{ + Name: strings.TrimPrefix(c.Name, "/"), + Image: c.Config.Image, + ImageID: c.Image, + Labels: c.Config.Labels, + Mounts: c.Mounts, + Running: c.State.Running, + }, nil +} + +// runContainerChecks adds the checks that only apply to a container. They +// run after deployment-kind has already established there is one. +// +// Both are blocking for `run` and advisory for `rehearse`, on the same +// reasoning as the deployment-kind check itself: rehearse never stops or +// recreates anything, and an operator doing the migration by hand needs +// these facts more than an automated run does. +func (c *Checker) runContainerChecks(ctx context.Context, runCheck checkFunc) error { + facts, factsErr := InspectContainer(ctx, c.opts.ContainerName) + + if _, err := runCheck("container-inspect", func() (CheckResult, string) { + if factsErr != nil { + return CheckResult{Status: StatusFail, Detail: factsErr.Error()}, "" + } + return CheckResult{Status: StatusOK, Detail: fmt.Sprintf( + "container %s runs image %s (%s)", facts.Name, facts.Image, shortID(facts.ImageID))}, facts.Image + }); err != nil { + return err + } + if factsErr != nil { + // The two checks below read facts we do not have. + return nil + } + + if _, err := runCheck("container-runtime", func() (CheckResult, string) { + project := facts.ComposeProject() + if project == "" { + return CheckResult{Status: StatusOK, Detail: "plain docker container, not compose-managed"}, "" + } + status := StatusFail + if c.opts.DeploymentCheckAdvisory { + status = StatusWarn + } + return CheckResult{Status: status, Detail: fmt.Sprintf( + "container is managed by docker compose (project %q). Recreating it out from under compose would leave the "+ + "running container and the compose file disagreeing about what is deployed, and the next `compose up` would "+ + "revert the migration. Migrate it by editing the image tag in the compose file and running `compose up -d`", + project)}, project + }); err != nil { + return err + } + + _, err := runCheck("container-data-volume", func() (CheckResult, string) { + writable := facts.WritableMounts() + if len(writable) == 0 { + status := StatusFail + if c.opts.DeploymentCheckAdvisory { + status = StatusWarn + } + return CheckResult{Status: status, Detail: "container has no writable volume or bind mount, so its data lives in " + + "the container's own writable layer - which is discarded when the container is replaced, and replacing it is " + + "what migrating it means. Move the data onto a volume before migrating"}, "" + } + // A data directory named but not covered by a mount is the same + // problem wearing a disguise, and worth saying separately: the + // mounts exist, they just are not where the data is. + if c.opts.DataDir != "" { + if m, ok := facts.MountFor(c.opts.DataDir); ok { + return CheckResult{Status: StatusOK, Detail: fmt.Sprintf( + "data dir %s is on a %s mount (%s)", c.opts.DataDir, m.Type, mountSource(m))}, m.Destination + } + status := StatusFail + if c.opts.DeploymentCheckAdvisory { + status = StatusWarn + } + return CheckResult{Status: status, Detail: fmt.Sprintf( + "data dir %s is not covered by any of the container's mounts (%s), so it lives in the writable layer and would "+ + "not survive the container being replaced. Check whether --data-dir names the path inside the container", + c.opts.DataDir, describeMounts(facts.Mounts))}, "" + } + return CheckResult{Status: StatusOK, Detail: "container has writable mounts: " + describeMounts(writable)}, "" + }) + return err +} + +func shortID(id string) string { + id = strings.TrimPrefix(id, "sha256:") + if len(id) > 12 { + return id[:12] + } + return id +} + +func mountSource(m Mount) string { + if m.Name != "" { + return m.Name + } + return m.Source +} + +func describeMounts(mounts []Mount) string { + if len(mounts) == 0 { + return "none" + } + parts := make([]string, 0, len(mounts)) + for _, m := range mounts { + parts = append(parts, m.Destination+" <- "+mountSource(m)) + } + return strings.Join(parts, ", ") +} diff --git a/internal/preflight/container_test.go b/internal/preflight/container_test.go new file mode 100644 index 0000000..67fcb85 --- /dev/null +++ b/internal/preflight/container_test.go @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2026 LINUXexpert-org +// SPDX-License-Identifier: GPL-3.0-or-later + +package preflight + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint" +) + +// fakeInspect writes a `docker` that answers `inspect` with the given JSON +// document, so the container checks can be exercised without a container. +func fakeInspect(t *testing.T, doc string) { + t.Helper() + dir := t.TempDir() + out := filepath.Join(dir, "inspect.json") + if err := os.WriteFile(out, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in inspect) cat %q ;; *) exit 1 ;; esac\n", out) + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func inspectDoc(t *testing.T, labels map[string]string, mounts []Mount) string { + t.Helper() + doc := []map[string]any{{ + "Name": "/stalwart", + "Image": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Config": map[string]any{"Image": "stalwartlabs/stalwart:v0.15.5", "Labels": labels}, + "State": map[string]any{"Running": true}, + "Mounts": mounts, + }} + b, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func dataVolume(dest string) Mount { + return Mount{Type: "volume", Name: "stalwart-data", Destination: dest, RW: true} +} + +func TestInspectContainerReadsTheFactsThatMatter(t *testing.T) { + fakeInspect(t, inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")})) + + facts, err := InspectContainer(context.Background(), "stalwart") + if err != nil { + t.Fatalf("InspectContainer: %v", err) + } + if facts.Name != "stalwart" { + t.Errorf("Name = %q, want stalwart (leading slash stripped)", facts.Name) + } + if facts.Image != "stalwartlabs/stalwart:v0.15.5" { + t.Errorf("Image = %q", facts.Image) + } + if facts.ComposeProject() != "mail" { + t.Errorf("ComposeProject() = %q, want mail", facts.ComposeProject()) + } + if !facts.Running { + t.Error("Running = false, want true") + } +} + +// A tag and the digest actually running can disagree - :latest is the +// obvious way, but any moved tag does it. Both are reported because only +// one of them says what is really running. +func TestInspectContainerKeepsTagAndDigestApart(t *testing.T) { + fakeInspect(t, inspectDoc(t, nil, nil)) + facts, err := InspectContainer(context.Background(), "stalwart") + if err != nil { + t.Fatal(err) + } + if facts.ImageID == facts.Image { + t.Error("ImageID and Image should be distinct - one is a tag, the other a digest") + } + if got := shortID(facts.ImageID); got != "0123456789ab" { + t.Errorf("shortID = %q, want 0123456789ab", got) + } +} + +func TestMountForPrefersTheMostSpecificMount(t *testing.T) { + facts := ContainerFacts{Mounts: []Mount{ + {Type: "bind", Source: "/srv", Destination: "/var/lib", RW: true}, + {Type: "volume", Name: "data", Destination: "/var/lib/stalwart/data", RW: true}, + }} + m, ok := facts.MountFor("/var/lib/stalwart/data/db") + if !ok { + t.Fatal("MountFor found nothing for a path under a mount") + } + if m.Name != "data" { + t.Errorf("MountFor returned %q, want the more specific 'data' mount", m.Name) + } + if _, ok := facts.MountFor("/etc/stalwart"); ok { + t.Error("MountFor matched a path no mount covers") + } +} + +// containerReport runs preflight against a fake container. +func containerReport(t *testing.T, doc string, dataDir string, advisory bool) Report { + t.Helper() + for _, p := range systemdUnitPaths { + if _, err := os.Stat(p); err == nil { + t.Skipf("host has %s, which detection prefers over docker", p) + } + } + fakeInspect(t, doc) + + counterPath := filepath.Join(t.TempDir(), "invocations") + binaryPath := writeFakeBinary(t, "0.15.5", counterPath) + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil { + t.Fatal(err) + } + withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"}) + }) + store := checkpoint.NewStore(t.TempDir()) + rs, err := store.Create("", "latest") + if err != nil { + t.Fatal(err) + } + if dataDir == "" { + dataDir = t.TempDir() + } + report, err := New(Options{ + BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, + TargetVersion: "latest", ToolCheckAdvisory: true, DeploymentCheckAdvisory: advisory, + }).Run(context.Background(), store, rs) + if err != nil { + t.Fatalf("Run: %v", err) + } + return report +} + +func resultFor(t *testing.T, r Report, name string) CheckResult { + t.Helper() + for _, res := range r.Results { + if res.Name == name { + return res + } + } + t.Fatalf("no %q result in report:\n%s", name, r.String()) + return CheckResult{} +} + +// A compose-managed container must be refused even once container cutover +// exists: recreating it desyncs the running container from the compose +// file, and the next `compose up` reverts the migration. +func TestComposeManagedContainerIsRefused(t *testing.T) { + doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")}) + res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime") + if res.Status != StatusFail { + t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusFail, res.Detail) + } +} + +func TestPlainContainerPassesTheRuntimeCheck(t *testing.T) { + doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")}) + res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime") + if res.Status != StatusOK { + t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusOK, res.Detail) + } +} + +// Data in the container's own writable layer does not survive the container +// being replaced, and replacing it is what migrating it means. +func TestContainerWithNoWritableMountIsRefused(t *testing.T) { + doc := inspectDoc(t, nil, nil) + res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-data-volume") + if res.Status != StatusFail { + t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail) + } +} + +// Mounts existing is not the same as the data being on one. +func TestDataDirOutsideEveryMountIsRefused(t *testing.T) { + doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")}) + res := resultFor(t, containerReport(t, doc, "/var/lib/stalwart", false), "container-data-volume") + if res.Status != StatusFail { + t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail) + } +} + +func TestDataDirOnAVolumePasses(t *testing.T) { + doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")}) + res := resultFor(t, containerReport(t, doc, "/opt/stalwart/data", false), "container-data-volume") + if res.Status != StatusOK { + t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusOK, res.Detail) + } +} + +// rehearse has to keep working against a container it cannot migrate - +// that is when its report is most useful - so the same findings are +// advisory there. +func TestRehearseReportsContainerProblemsWithoutBlocking(t *testing.T) { + doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, nil) + // A real directory, because disk-space stats DataDir on the host. That + // a container-internal path breaks host-side checks is true and is + // PR 3's problem (path translation); it is not what this is testing. + report := containerReport(t, doc, t.TempDir(), true) + if report.Blocking() { + t.Fatalf("advisory mode should not block:\n%s", report.String()) + } + for _, name := range []string{"container-runtime", "container-data-volume"} { + if got := resultFor(t, report, name).Status; got != StatusWarn { + t.Errorf("%s = %q, want %q in advisory mode", name, got, StatusWarn) + } + } +}