Merge pull request #4 from LINUXexpert-org/docker-preflight-facts
Tell a container operator what stands in the way
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user