Merge pull request #4 from LINUXexpert-org/docker-preflight-facts

Tell a container operator what stands in the way
This commit is contained in:
LINUXexpert.org
2026-08-28 16:59:39 -07:00
committed by GitHub
4 changed files with 468 additions and 6 deletions
+9
View File
@@ -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 {
+4 -6
View File
@@ -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
+235
View File
@@ -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, ", ")
}
+220
View File
@@ -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)
}
}
}