Files
stalwart-migrator/internal/preflight/container.go
T
jcoffey-dev 272439cf2a Wire the container path up, behind a flag that says what it is
Everything the container migration needs has landed a piece at a time and
nothing called any of it. `run` now does: stage pulls and verifies an image
instead of downloading a binary, the recovery cycle launches a throwaway
container against the live container's own mounts, and cutover recreates it.
Preflight's blanket refusal of docker goes with it -- what still refuses is
specific to a container rather than to containers, which is compose and
data that is not on a volume.

It refuses without --container-path-unproven, and that flag is the honest
part of this change. Every test drives a fake docker. That proves the right
commands are assembled and proves nothing about whether a real image reads
the config it is handed -- which is the exact limit ARCHITECTURE.md section
4.8 records about the rollback code that was deleted for being tested only
against fakes. A doc note seemed too quiet for a tool that stops a mail
server, so it is a flag nobody reaches without being told.

The converted config reaches the container through the data volume. It is
written under the host side of whichever mount covers --data-dir and named
on the container side, because cutover recreates a container with the mounts
it had and cannot invent a new one for a config file. --data-dir therefore
names the path inside the container, which preflight already says when it
matches no mount.

PatchPaths stays unused, deliberately. Its documented purpose is pointing a
rehearsal at a sandbox; a real container's dumped settings already carry
container-side paths, because they come from the live server rather than
from a file on this host.

The preflight test that asserted docker was refused outright now asserts
the replacement rather than being deleted -- "docker is allowed through
here" is the thing that would be wrong to regress. Its fixture had to make
--data-dir both a real host directory and one the fake container mounts,
since disk-space stats it and container-data-volume wants it covered.

README gains the container section and, at the top, the note that this is
ihasmail's companion.
2026-08-28 17:42:31 -07:00

336 lines
13 KiB
Go

// 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
// The rest is what cutover would have to carry across when it recreates
// the container. Recreating is the container equivalent of rewriting a
// unit file, except that a unit can be edited in place and a container
// cannot - so anything not carried here is silently dropped, which is
// the failure UnsupportedForRecreate exists to prevent.
Env []string
Ports map[string][]PortBinding
RestartPolicy string
NetworkMode string
Unsupported []string // populated by UnsupportedForRecreate
}
// PortBinding is one published port.
type PortBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}
// 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"`
Env []string `json:"Env"`
User string `json:"User"`
} `json:"Config"`
State struct {
Running bool `json:"Running"`
} `json:"State"`
Mounts []Mount `json:"Mounts"`
HostConfig struct {
PortBindings map[string][]PortBinding `json:"PortBindings"`
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
NetworkMode string `json:"NetworkMode"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Devices []any `json:"Devices"`
Sysctls map[string]string `json:"Sysctls"`
Ulimits []any `json:"Ulimits"`
Privileged bool `json:"Privileged"`
ExtraHosts []string `json:"ExtraHosts"`
DNS []string `json:"Dns"`
GroupAdd []string `json:"GroupAdd"`
SecurityOpt []string `json:"SecurityOpt"`
Tmpfs map[string]string `json:"Tmpfs"`
LogConfig struct {
Type string `json:"Type"`
} `json:"LogConfig"`
} `json:"HostConfig"`
NetworkSettings struct {
Networks map[string]any `json:"Networks"`
} `json:"NetworkSettings"`
}
// 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]
f := ContainerFacts{
Name: strings.TrimPrefix(c.Name, "/"),
Image: c.Config.Image,
ImageID: c.Image,
Labels: c.Config.Labels,
Mounts: c.Mounts,
Running: c.State.Running,
Env: c.Config.Env,
Ports: c.HostConfig.PortBindings,
RestartPolicy: c.HostConfig.RestartPolicy.Name,
NetworkMode: c.HostConfig.NetworkMode,
}
f.Unsupported = unsupportedForRecreate(c)
return f, nil
}
// unsupportedForRecreate names every piece of this container's
// configuration that recreating it would not carry across.
//
// Cutover recreates rather than edits, because a container cannot be edited
// in place the way a unit file can. That makes silent loss the default
// failure: a container recreated without its capabilities, its custom
// network or its device mappings starts cleanly and is quietly not the
// server it was. §4.5 already refuses to edit a unit line it only partly
// understands; this is the same rule, applied where the whole definition
// has to be rebuilt.
//
// The list is deliberately conservative and deliberately not exhaustive -
// docker's HostConfig has far more fields than these. It names the ones a
// mail server plausibly uses, and anything it does not know about is a
// reason this tool should not be recreating that container at all.
func unsupportedForRecreate(c inspectOutput) []string {
var out []string
add := func(cond bool, what string) {
if cond {
out = append(out, what)
}
}
h := c.HostConfig
add(len(h.CapAdd) > 0, "added capabilities (--cap-add)")
add(len(h.CapDrop) > 0, "dropped capabilities (--cap-drop)")
add(len(h.Devices) > 0, "device mappings (--device)")
add(len(h.Sysctls) > 0, "sysctls (--sysctl)")
add(len(h.Ulimits) > 0, "ulimits (--ulimit)")
add(h.Privileged, "privileged mode (--privileged)")
add(len(h.ExtraHosts) > 0, "extra hosts (--add-host)")
add(len(h.DNS) > 0, "custom DNS (--dns)")
add(len(h.GroupAdd) > 0, "supplementary groups (--group-add)")
add(len(h.SecurityOpt) > 0, "security options (--security-opt)")
add(len(h.Tmpfs) > 0, "tmpfs mounts (--tmpfs)")
add(h.LogConfig.Type != "" && h.LogConfig.Type != "json-file", "a non-default log driver (--log-driver "+h.LogConfig.Type+")")
add(c.Config.User != "", "a container user (--user "+c.Config.User+")")
// A user-defined network is a name in NetworkSettings.Networks that is
// not one of docker's built-ins. Recreating without it puts the server
// somewhere nothing else can reach it.
for name := range c.NetworkSettings.Networks {
switch name {
case "bridge", "host", "none":
default:
out = append(out, "a user-defined network ("+name+")")
}
}
return out
}
// 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
}
// DescribeMounts renders mounts for an operator-facing message.
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, ", ")
}