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.
255 lines
8.7 KiB
Go
255 lines
8.7 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package cutover
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
)
|
|
|
|
// inspectJSON builds a `docker inspect` document. extra is merged into
|
|
// HostConfig so a test can add the configuration a recreate would drop.
|
|
func inspectJSON(t *testing.T, extraHost map[string]any, networks map[string]any) string {
|
|
t.Helper()
|
|
host := map[string]any{
|
|
"PortBindings": map[string]any{"143/tcp": []map[string]string{{"HostIp": "0.0.0.0", "HostPort": "143"}}},
|
|
"RestartPolicy": map[string]any{"Name": "unless-stopped"},
|
|
"NetworkMode": "bridge",
|
|
"LogConfig": map[string]any{"Type": "json-file"},
|
|
}
|
|
for k, v := range extraHost {
|
|
host[k] = v
|
|
}
|
|
if networks == nil {
|
|
networks = map[string]any{"bridge": map[string]any{}}
|
|
}
|
|
doc := []map[string]any{{
|
|
"Name": "/stalwart",
|
|
"Image": "sha256:old",
|
|
"Config": map[string]any{
|
|
"Image": "stalwartlabs/stalwart:v0.15.5",
|
|
"Env": []string{"TZ=UTC", "STALWART_RECOVERY_MODE=1"},
|
|
},
|
|
"State": map[string]any{"Running": false},
|
|
"Mounts": []map[string]any{{"Type": "volume", "Name": "stalwart-data", "Destination": "/opt/stalwart", "RW": true}},
|
|
"HostConfig": host,
|
|
"NetworkSettings": map[string]any{"Networks": networks},
|
|
}}
|
|
b, err := json.Marshal(doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// fakeDockerCutover installs a docker that records arguments and serves the
|
|
// given inspect document.
|
|
func fakeDockerCutover(t *testing.T, doc string) (log string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
log = filepath.Join(dir, "args.log")
|
|
inspectFile := filepath.Join(dir, "inspect.json")
|
|
if err := os.WriteFile(inspectFile, []byte(doc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
echo "$@" >> %q
|
|
case "$1" in
|
|
inspect) cat %q ;;
|
|
rename) exit 0 ;;
|
|
run) echo newcontainerid ;;
|
|
esac
|
|
`, log, inspectFile)
|
|
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"))
|
|
return log
|
|
}
|
|
|
|
func runContainerFor(t *testing.T, doc string) (*checkpoint.RunState, Report, error) {
|
|
t.Helper()
|
|
fakeDockerCutover(t, doc)
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("0.15.5", "0.16.14")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var report Report
|
|
step := func(name string, fn func() (checkpoint.StepOutcome, error)) error {
|
|
outcome, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
|
|
if err != nil {
|
|
report.Results = append(report.Results, CheckResult{Name: name, Status: StatusFail, Detail: err.Error()})
|
|
return err
|
|
}
|
|
report.Results = append(report.Results, CheckResult{Name: name, Status: StatusOK, Detail: outcome.Detail})
|
|
return nil
|
|
}
|
|
err = runContainerCutover(context.Background(), rs, step, ContainerOptions{
|
|
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
|
|
})
|
|
return rs, report, err
|
|
}
|
|
|
|
func TestContainerCutoverPreservesTheDefinitionFirst(t *testing.T) {
|
|
rs, _, err := runContainerFor(t, inspectJSON(t, nil, nil))
|
|
if err != nil {
|
|
t.Fatalf("runContainerCutover: %v", err)
|
|
}
|
|
art, ok := rs.Artifacts[ArtifactContainerDefinition]
|
|
if !ok {
|
|
t.Fatal("the container definition was not recorded as an artifact")
|
|
}
|
|
if art.SHA256 == "" || art.SizeBytes == 0 {
|
|
t.Errorf("artifact recorded without a checksum or size: %+v", art)
|
|
}
|
|
body, err := os.ReadFile(art.Path)
|
|
if err != nil {
|
|
t.Fatalf("preserved definition unreadable: %v", err)
|
|
}
|
|
if !strings.Contains(string(body), "stalwart-data") {
|
|
t.Error("preserved definition does not contain the container's mounts")
|
|
}
|
|
}
|
|
|
|
// The old container is kept, not removed: with the old image unpruned it is
|
|
// the manual restore path (ARCHITECTURE.md §4.8).
|
|
func TestContainerCutoverRetiresRatherThanRemoves(t *testing.T) {
|
|
log := fakeDockerCutover(t, inspectJSON(t, nil, nil))
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, _ := store.Create("0.15.5", "0.16.14")
|
|
step := func(name string, fn func() (checkpoint.StepOutcome, error)) error {
|
|
_, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
|
|
return err
|
|
}
|
|
if err := runContainerCutover(context.Background(), rs, step, ContainerOptions{
|
|
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
args := readLog(t, log)
|
|
if !strings.Contains(args, "rename stalwart stalwart-premigration-0.15.5") {
|
|
t.Errorf("old container was not retired by rename:\n%s", args)
|
|
}
|
|
if strings.Contains(args, "rm stalwart") || strings.Contains(args, "image rm") || strings.Contains(args, "prune") {
|
|
t.Errorf("cutover removed something it should have kept:\n%s", args)
|
|
}
|
|
}
|
|
|
|
func TestContainerCutoverRecreatesWithTheCarriedSettings(t *testing.T) {
|
|
log := fakeDockerCutover(t, inspectJSON(t, nil, nil))
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, _ := store.Create("0.15.5", "0.16.14")
|
|
step := func(name string, fn func() (checkpoint.StepOutcome, error)) error {
|
|
_, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
|
|
return err
|
|
}
|
|
if err := runContainerCutover(context.Background(), rs, step, ContainerOptions{
|
|
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
args := readLog(t, log)
|
|
for _, want := range []string{
|
|
"run -d --name stalwart",
|
|
"--restart unless-stopped",
|
|
"-e TZ=UTC",
|
|
"-v stalwart-data:/opt/stalwart",
|
|
"-p 0.0.0.0:143:143",
|
|
"sha256:new",
|
|
} {
|
|
if !strings.Contains(args, want) {
|
|
t.Errorf("recreate missing %q\ngot: %s", want, args)
|
|
}
|
|
}
|
|
// Leaving recovery mode set would recovery-boot on every restart - the
|
|
// footgun §4.5 strips from a unit.
|
|
if strings.Contains(args, "STALWART_RECOVERY_MODE") {
|
|
t.Errorf("recovery-mode env survived into the recreated container:\n%s", args)
|
|
}
|
|
}
|
|
|
|
// The hazard this design exists for: a container carrying configuration a
|
|
// recreate would drop must be refused, not quietly rebuilt without it.
|
|
func TestContainerCutoverRefusesConfigurationItWouldDrop(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
host map[string]any
|
|
nets map[string]any
|
|
wants string
|
|
}{
|
|
{"capabilities", map[string]any{"CapAdd": []string{"NET_ADMIN"}}, nil, "capabilities"},
|
|
{"devices", map[string]any{"Devices": []any{map[string]any{}}}, nil, "device mappings"},
|
|
{"sysctls", map[string]any{"Sysctls": map[string]string{"net.core.somaxconn": "1024"}}, nil, "sysctls"},
|
|
{"privileged", map[string]any{"Privileged": true}, nil, "privileged"},
|
|
{"log driver", map[string]any{"LogConfig": map[string]any{"Type": "syslog"}}, nil, "log driver"},
|
|
{"user network", nil, map[string]any{"mailnet": map[string]any{}}, "user-defined network"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, err := runContainerFor(t, inspectJSON(t, tc.host, tc.nets))
|
|
if err == nil {
|
|
t.Fatalf("expected a refusal for a container with %s", tc.name)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wants) {
|
|
t.Errorf("refusal should name %q, got: %v", tc.wants, err)
|
|
}
|
|
if !strings.Contains(err.Error(), "by hand") {
|
|
t.Errorf("refusal should tell the operator what to do instead, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A refusal must happen before anything is touched.
|
|
func TestContainerCutoverRefusesBeforeRetiringAnything(t *testing.T) {
|
|
log := fakeDockerCutover(t, inspectJSON(t, map[string]any{"Privileged": true}, nil))
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, _ := store.Create("0.15.5", "0.16.14")
|
|
step := func(name string, fn func() (checkpoint.StepOutcome, error)) error {
|
|
_, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
|
|
return err
|
|
}
|
|
if err := runContainerCutover(context.Background(), rs, step, ContainerOptions{
|
|
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
|
|
}); err == nil {
|
|
t.Fatal("expected a refusal")
|
|
}
|
|
if args := readLog(t, log); strings.Contains(args, "rename") || strings.Contains(args, "run -d") {
|
|
t.Errorf("refusal came after the container was already changed:\n%s", args)
|
|
}
|
|
}
|
|
|
|
func TestContainerCutoverNeedsAStagedImage(t *testing.T) {
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, _ := store.Create("0.15.5", "0.16.14")
|
|
step := func(name string, fn func() (checkpoint.StepOutcome, error)) error {
|
|
_, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
|
|
return err
|
|
}
|
|
if err := runContainerCutover(context.Background(), rs, step, ContainerOptions{
|
|
ContainerName: "stalwart", PreserveDir: t.TempDir(),
|
|
}); err == nil {
|
|
t.Fatal("expected a refusal with no staged image")
|
|
}
|
|
}
|
|
|
|
func readLog(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return ""
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|