Tell a container operator what stands in the way
Preflight refuses a container, and until now that was all it said. The two things that actually decide whether one could be migrated at all were never looked at, so an operator was refused without being told what to fix or what a manual migration would involve. It now inspects the container and reports three things. What image it is running, tag and digest kept apart because a moved tag makes them disagree and only one of them says what is really there. Whether compose manages it, which matters beyond this tool's current refusal: recreating a compose-managed container out from under compose leaves the container and the compose file disagreeing about what is deployed, and the next `compose up` reverts the migration. And whether the data is on a volume at all -- a container keeping it in its own writable layer loses it when the container is replaced, and replacing the container is what migrating it means, so that one is fatal in a way no later phase could recover from. All three are advisory under rehearse, on the same reasoning that already made the deployment check advisory there: rehearse never stops or recreates anything, and an operator doing this by hand needs these facts more than an automated run does. Preflight stays read-only. Preserving the container definition as an artifact belongs with the phase that replaces it, so it goes with cutover rather than here. One limitation this surfaced and does not fix: --data-dir naming a path inside the container breaks the host-side disk-space check, which stats it locally. Path translation is the next piece of work; the data-volume check says so when the path matches no mount.
This commit is contained in:
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user