Files
jcoffey-dev 12ec0c3fd4 Rename the module to the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. Here that is not a
documentation change: the old organisation was the module path, so it is
declared in go.mod and repeated in every internal import.

Leaving it would have been worse than a stale link. GitHub redirects the
repository, but a go.mod whose module line disagrees with the path it was
fetched from is an error rather than a redirect, so `go get` on the new
address would have failed against the old declaration.

go.mod, 34 files of imports, and the repository links in README and
ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved,
only our own path.
2026-08-30 15:24:18 -07:00

432 lines
16 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package cutover
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Coffey-Labs/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"},
// Matching imageJSON's defaults, so nothing here reads as an
// override. A container off the official image reports all
// three having been given none of them.
"User": imageUser,
"Entrypoint": imageEntrypoint,
"Cmd": imageCmd,
},
"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)
}
// The defaults the official Stalwart image gives every container made
// from it. They are here rather than inline because the whole point of
// the image comparison is that a container reporting exactly these has
// overridden nothing.
var (
imageUser = "stalwart"
imageEntrypoint = []string{"/usr/local/bin/stalwart"}
imageCmd = []string{"--config", "/etc/stalwart/config.json"}
)
// imageJSON is `docker image inspect` for the image a container is on.
func imageJSON(t *testing.T) string {
t.Helper()
b, err := json.Marshal([]map[string]any{{
"Config": map[string]any{"User": imageUser, "Entrypoint": imageEntrypoint, "Cmd": imageCmd},
}})
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)
}
imageFile := filepath.Join(dir, "image.json")
if err := os.WriteFile(imageFile, []byte(imageJSON(t)), 0o644); err != nil {
t.Fatal(err)
}
script := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
case "$1 $2" in
"image inspect") cat %q ; exit 0 ;;
esac
case "$1" in
inspect) cat %q ;;
rename) exit 0 ;;
run) echo newcontainerid ;;
esac
`, log, imageFile, 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(),
ConfigPath: containerConfig,
})
return rs, report, err
}
// containerConfig is the container-side path to the migrated config that
// run passes to cutover, mirroring run.go's <data mount>/stalwart-migrate.
const containerConfig = "/opt/stalwart/stalwart-migrate/config.json"
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)
}
// The recreated container has to be started on the config the migration
// just produced. Left to the image's own default command it would come up
// on /etc/stalwart/config.json - a different volume from the data
// directory, holding whatever the old version left there - and be a
// server with nothing to do with the migration that preceded it.
// @kaya-eu did this step by hand on three real migrations.
func TestContainerCutoverStartsOnTheMigratedConfig(t *testing.T) {
log := fakeDockerCutover(t, inspectJSON(t, nil, nil))
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
if err := runContainerCutover(context.Background(), rs, noopStep(store, rs), ContainerOptions{
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
ConfigPath: containerConfig,
}); err != nil {
t.Fatalf("runContainerCutover: %v", err)
}
run := runLine(t, log)
if !strings.Contains(run, "--config "+containerConfig) {
t.Errorf("run should start the container on the migrated config, got: %s", run)
}
// After the image, not before: everything past it is the argv.
if strings.Index(run, "sha256:new") > strings.Index(run, "--config "+containerConfig) {
t.Errorf("--config must come after the image, got: %s", run)
}
}
// An inherited USER belongs to the image. Passing --user stalwart to the
// new image would be carrying across a decision nobody made, and would
// break outright on an image that named its user differently.
func TestContainerCutoverDoesNotCarryInheritedDefaults(t *testing.T) {
log := fakeDockerCutover(t, inspectJSON(t, nil, nil))
store := checkpoint.NewStore(t.TempDir())
rs, _ := store.Create("0.15.5", "0.16.14")
if err := runContainerCutover(context.Background(), rs, noopStep(store, rs), ContainerOptions{
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
ConfigPath: containerConfig,
}); err != nil {
t.Fatalf("runContainerCutover: %v", err)
}
run := runLine(t, log)
for _, unwanted := range []string{"--user", "--entrypoint"} {
if strings.Contains(run, unwanted) {
t.Errorf("run carried %s across from the old image's defaults: %s", unwanted, run)
}
}
}
// What the operator did override is theirs, and a recreate that drops it
// starts cleanly as a different server - the failure Unsupported exists to
// prevent, for two settings that were not being read at all.
func TestContainerCutoverCarriesTheOperatorsOverrides(t *testing.T) {
doc := inspectJSON(t, nil, nil)
doc = strings.Replace(doc, `"User":"stalwart"`, `"User":"1500:1500"`, 1)
doc = strings.Replace(doc, `"Entrypoint":["/usr/local/bin/stalwart"]`,
`"Entrypoint":["/usr/local/bin/wrapper","--trace"]`, 1)
log := fakeDockerCutover(t, doc)
store := checkpoint.NewStore(t.TempDir())
rs, _ := store.Create("0.15.5", "0.16.14")
if err := runContainerCutover(context.Background(), rs, noopStep(store, rs), ContainerOptions{
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
ConfigPath: containerConfig,
}); err != nil {
t.Fatalf("runContainerCutover: %v", err)
}
run := runLine(t, log)
if !strings.Contains(run, "--user 1500:1500") {
t.Errorf("run should carry the overridden user: %s", run)
}
// docker run takes one word as --entrypoint; the rest is argv.
if !strings.Contains(run, "--entrypoint /usr/local/bin/wrapper") {
t.Errorf("run should carry the overridden entrypoint: %s", run)
}
if !strings.Contains(run, "sha256:new --trace") {
t.Errorf("the rest of the entrypoint should lead the argv: %s", run)
}
}
// A command of the operator's own and the config this tool has to hand
// over are the same argv. Their command may point at another config, or
// at something that is not the server - so this refuses rather than
// merging and being quietly wrong about which server came up.
func TestContainerCutoverRefusesToMergeACommandWithTheConfig(t *testing.T) {
doc := strings.Replace(inspectJSON(t, nil, nil),
`"Cmd":["--config","/etc/stalwart/config.json"]`, `"Cmd":["--config","/srv/mine.toml"]`, 1)
_, _, err := runContainerFor(t, doc)
if err == nil {
t.Fatal("want a refusal when an overridden command collides with the migrated config")
}
for _, want := range []string{"/srv/mine.toml", containerConfig, "by hand"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal should name %q, got: %v", want, err)
}
}
}
// A patch bump converts nothing, so there is no config to point at and
// the container keeps the command its image gives it.
func TestContainerCutoverKeepsTheImageCommandWithoutAConfig(t *testing.T) {
log := fakeDockerCutover(t, inspectJSON(t, nil, nil))
store := checkpoint.NewStore(t.TempDir())
rs, _ := store.Create("0.16.14", "0.16.19")
if err := runContainerCutover(context.Background(), rs, noopStep(store, rs), ContainerOptions{
ContainerName: "stalwart", StagedImage: "sha256:new", PreserveDir: t.TempDir(),
}); err != nil {
t.Fatalf("runContainerCutover: %v", err)
}
if run := runLine(t, log); !strings.HasSuffix(strings.TrimSpace(run), "sha256:new") {
t.Errorf("run should end at the image, with no argv of its own: %s", run)
}
}
func noopStep(store *checkpoint.Store, rs *checkpoint.RunState) stepFunc {
return func(name string, fn func() (checkpoint.StepOutcome, error)) error {
_, err := store.RunStep(rs, checkpoint.PhaseCutover, name, fn)
return err
}
}
// runLine is the `docker run` the fake recorded.
func runLine(t *testing.T, log string) string {
t.Helper()
data, err := os.ReadFile(log)
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "run ") {
return line
}
}
t.Fatalf("no `docker run` in the recorded arguments:\n%s", data)
return ""
}