A container cannot be edited in place the way a unit file can, so cutting one over means rebuilding it. 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. Section 4.5 already answers this for a unit file -- it rewrites in place rather than regenerating, because a generated unit would drop hardening options this tool has no business having an opinion about, and it refuses to edit a line it only partly understands. The same rule applies here, where the whole definition has to be rebuilt: the parts this understands are carried across, and a container using anything else is refused by name rather than rebuilt without it. The list of what it looks for is conservative and not exhaustive, which is the safe direction: docker's HostConfig has far more fields, and one this does not know about is a reason not to be recreating that container at all. The old container is renamed, not removed, and nothing here prunes the old image. Together they are the container's manual restore path -- one command starts the previous container again -- which is as close to section 4.2's preserved binary as a container gets. The inspect output is preserved as an artifact before anything is replaced, for the reason the unit file is: an operator putting a machine back by hand should not also be reconstructing the definition from memory. Recovery-mode variables are stripped from the recreated container's environment. Leaving STALWART_RECOVERY_MODE set would recovery-boot on every restart, which is the same footgun the unit rewrite exists to prevent. Run now branches, with the health check and quota recalculation shared: those ask the same question whatever started the server. The binary path moved inside an else and is otherwise untouched -- no existing test needed editing, which is the evidence for that. The container path is opt-in through Options.Container, so a Docker deployment without it is still refused exactly as before. Nothing calls it yet; wiring `run` up and lifting preflight's refusal is what remains of #3, and ARCHITECTURE.md says so in both places it previously said Docker was refused outright.
188 lines
6.9 KiB
Go
188 lines
6.9 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package cutover
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/preflight"
|
|
)
|
|
|
|
// ArtifactContainerDefinition is the preserved `docker inspect` of the
|
|
// container as it was before cutover replaced it - the container's
|
|
// equivalent of ArtifactServiceUnit, and for the same reason. Recovery is
|
|
// out of scope (ARCHITECTURE.md §4.8), so what this tool owes an operator
|
|
// putting a machine back by hand is the definition they would otherwise be
|
|
// reconstructing from memory.
|
|
const ArtifactContainerDefinition = "container-definition"
|
|
|
|
// ContainerOptions configures cutting over a container deployment.
|
|
type ContainerOptions struct {
|
|
// ContainerName is the live container, which must already be stopped.
|
|
ContainerName string
|
|
|
|
// StagedImage is the image ID stage verified - an ID rather than a tag
|
|
// deliberately, so what runs is what was checked.
|
|
StagedImage string
|
|
|
|
// PreserveDir is where the inspected definition is written.
|
|
PreserveDir string
|
|
|
|
DockerBinary string
|
|
}
|
|
|
|
func (o ContainerOptions) docker() string {
|
|
if o.DockerBinary == "" {
|
|
return "docker"
|
|
}
|
|
return o.DockerBinary
|
|
}
|
|
|
|
// runContainerCutover replaces the container with one running the staged
|
|
// image, carrying across the parts of its definition this tool understands
|
|
// and refusing outright when it finds parts it does not.
|
|
//
|
|
// The old container is renamed rather than removed, and the old image is
|
|
// never pruned. Together they are the manual restore path: an operator can
|
|
// start the previous container again with one command, which is as close to
|
|
// the preserved-binary guarantee (§4.2) as a container gets.
|
|
func runContainerCutover(ctx context.Context, rs *checkpoint.RunState, step stepFunc, opts ContainerOptions) error {
|
|
if opts.ContainerName == "" {
|
|
return fmt.Errorf("cutover: no container name")
|
|
}
|
|
if opts.StagedImage == "" {
|
|
return fmt.Errorf("cutover: no staged image - stage the target image before cutting over to it")
|
|
}
|
|
|
|
var facts preflight.ContainerFacts
|
|
|
|
if err := step("preserve-container-definition", func() (checkpoint.StepOutcome, error) {
|
|
raw, err := dockerOut(ctx, opts.docker(), "inspect", opts.ContainerName)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, fmt.Errorf("inspect %s: %w (%s)", opts.ContainerName, err, raw)
|
|
}
|
|
if opts.PreserveDir == "" {
|
|
return checkpoint.StepOutcome{}, fmt.Errorf("no directory to preserve the container definition in")
|
|
}
|
|
if err := os.MkdirAll(opts.PreserveDir, 0o750); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
dest := filepath.Join(opts.PreserveDir, opts.ContainerName+".inspect.json")
|
|
if err := os.WriteFile(dest, []byte(raw), 0o640); err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
sum, size, err := hashFile(dest)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
// Recorded before anything is replaced, so a crash between
|
|
// preserving and recreating still leaves the original findable.
|
|
rs.RecordArtifact(ArtifactContainerDefinition, checkpoint.Artifact{Path: dest, SHA256: sum, SizeBytes: size})
|
|
|
|
facts, err = preflight.InspectContainer(ctx, opts.ContainerName)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
return checkpoint.StepOutcome{Detail: "preserved the container definition at " + dest}, nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := step("container-is-recreatable", func() (checkpoint.StepOutcome, error) {
|
|
if len(facts.Unsupported) > 0 {
|
|
return checkpoint.StepOutcome{}, fmt.Errorf(
|
|
"this container uses configuration cutting over would not carry across: %s. Recreating it without those would "+
|
|
"start cleanly and quietly not be the server it was, so this tool will not do it. Migrate this one by hand: "+
|
|
"the definition is preserved as %s, and the staged image is %s",
|
|
strings.Join(facts.Unsupported, "; "), rs.Artifacts[ArtifactContainerDefinition].Path, opts.StagedImage)
|
|
}
|
|
return checkpoint.StepOutcome{Detail: "the container's definition is entirely within what a recreate carries across"}, nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
retired := opts.ContainerName + "-premigration"
|
|
if rs.SourceVersion != "" {
|
|
retired += "-" + rs.SourceVersion
|
|
}
|
|
|
|
if err := step("retire-old-container", func() (checkpoint.StepOutcome, error) {
|
|
// Renamed, not removed. The old container plus the old image - which
|
|
// nothing here prunes - is what an operator restores by hand.
|
|
if out, err := dockerOut(ctx, opts.docker(), "rename", opts.ContainerName, retired); err != nil {
|
|
return checkpoint.StepOutcome{}, fmt.Errorf("rename %s: %w (%s)", opts.ContainerName, err, out)
|
|
}
|
|
return checkpoint.StepOutcome{Detail: fmt.Sprintf("kept the previous container as %s, still on image %s", retired, facts.Image)}, nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return step("create-container", func() (checkpoint.StepOutcome, error) {
|
|
args := []string{"run", "-d", "--name", opts.ContainerName}
|
|
if facts.RestartPolicy != "" && facts.RestartPolicy != "no" {
|
|
args = append(args, "--restart", facts.RestartPolicy)
|
|
}
|
|
for _, e := range facts.Env {
|
|
// Recovery-mode variables must never survive into a normal
|
|
// start: leaving STALWART_RECOVERY_MODE set would recovery-boot
|
|
// on every restart, the same footgun §4.5 strips from a unit.
|
|
if strings.HasPrefix(e, "STALWART_RECOVERY_") {
|
|
continue
|
|
}
|
|
args = append(args, "-e", e)
|
|
}
|
|
for _, m := range facts.Mounts {
|
|
src := m.Name
|
|
if src == "" {
|
|
src = m.Source
|
|
}
|
|
spec := src + ":" + m.Destination
|
|
if !m.RW {
|
|
spec += ":ro"
|
|
}
|
|
args = append(args, "-v", spec)
|
|
}
|
|
for port, bindings := range facts.Ports {
|
|
for _, b := range bindings {
|
|
spec := b.HostPort + ":" + strings.SplitN(port, "/", 2)[0]
|
|
if b.HostIP != "" {
|
|
spec = b.HostIP + ":" + spec
|
|
}
|
|
args = append(args, "-p", spec)
|
|
}
|
|
}
|
|
for k, v := range facts.Labels {
|
|
args = append(args, "--label", k+"="+v)
|
|
}
|
|
args = append(args, opts.StagedImage)
|
|
|
|
if out, err := dockerOut(ctx, opts.docker(), args...); err != nil {
|
|
return checkpoint.StepOutcome{}, fmt.Errorf(
|
|
"create %s from %s: %w (%s). The previous container is still here as %s",
|
|
opts.ContainerName, opts.StagedImage, err, out, retired)
|
|
}
|
|
return checkpoint.StepOutcome{Detail: fmt.Sprintf("recreated %s on image %s", opts.ContainerName, opts.StagedImage)}, nil
|
|
})
|
|
}
|
|
|
|
// stepFunc is cutover.Run's checkpointed step runner, passed in so the
|
|
// container path reports through the same Report the systemd path does.
|
|
type stepFunc func(name string, fn func() (checkpoint.StepOutcome, error)) error
|
|
|
|
func dockerOut(ctx context.Context, name string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
err := cmd.Run()
|
|
return strings.TrimSpace(out.String()), err
|
|
}
|