Files
stalwart-migrator/internal/preflight/container_test.go
T
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00

380 lines
14 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// The defaults the official Stalwart image gives every container made from
// it. A container reporting exactly these has overridden nothing, which is
// the case the image comparison exists to recognise - `docker inspect`
// reports all three either way.
// fakeImageID is the digest inspectDoc reports, and what the fake answers
// `inspect -f {{.Image}}` with. fakeContainerVersion is what running that
// image prints for --version - the same 0.15.5 the fake host binary
// reports, so the docker and binary paths are testing the same migration.
const (
fakeImageID = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
fakeContainerVersion = "stalwart 0.15.5"
)
var (
imageUser = "stalwart"
imageEntrypoint = []string{"/usr/local/bin/stalwart"}
imageCmd = []string{"--config", "/etc/stalwart/config.json"}
)
// fakeInspect writes a `docker` that answers `inspect` with the given JSON
// document, so the container checks can be exercised without a container.
// `image inspect` answers with the official image's own defaults.
func fakeInspect(t *testing.T, doc string) {
t.Helper()
fakeInspectOn(t, doc, imageDoc(t, imageUser, imageEntrypoint, imageCmd))
}
// fakeInspectOn is fakeInspect with the image's defaults named, for the
// tests that need the container and its image to disagree.
func fakeInspectOn(t *testing.T, containerDoc, imgDoc string) {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, "inspect.json")
if err := os.WriteFile(out, []byte(containerDoc), 0o644); err != nil {
t.Fatal(err)
}
img := filepath.Join(dir, "image.json")
if err := os.WriteFile(img, []byte(imgDoc), 0o644); err != nil {
t.Fatal(err)
}
// Four questions this tool asks docker: the container's definition,
// the image's defaults, the container's image ID, and what that image
// reports as its version.
script := fmt.Sprintf("#!/bin/sh\n"+
"case \"$1 $2\" in \"image inspect\") cat %q ; exit 0 ;; esac\n"+
"case \"$1\" in\n"+
" inspect) if [ \"$2\" = \"-f\" ]; then echo %q; else cat %q; fi ;;\n"+
" run) echo %q ;;\n"+
" *) exit 1 ;;\n"+
"esac\n", img, fakeImageID, out, fakeContainerVersion)
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"))
}
func imageDoc(t *testing.T, user string, entrypoint, cmd []string) string {
t.Helper()
b, err := json.Marshal([]map[string]any{{
"Config": map[string]any{"User": user, "Entrypoint": entrypoint, "Cmd": cmd},
}})
if err != nil {
t.Fatal(err)
}
return string(b)
}
func inspectDoc(t *testing.T, labels map[string]string, mounts []Mount) string {
t.Helper()
doc := []map[string]any{{
"Name": "/stalwart",
"Image": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"Config": map[string]any{
"Image": "stalwartlabs/stalwart:v0.15.5", "Labels": labels,
"User": imageUser, "Entrypoint": imageEntrypoint, "Cmd": imageCmd,
},
"State": map[string]any{"Running": true},
"Mounts": mounts,
}}
b, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func dataVolume(dest string) Mount {
return Mount{Type: "volume", Name: "stalwart-data", Destination: dest, RW: true}
}
func TestInspectContainerReadsTheFactsThatMatter(t *testing.T) {
fakeInspect(t, inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")}))
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if facts.Name != "stalwart" {
t.Errorf("Name = %q, want stalwart (leading slash stripped)", facts.Name)
}
if facts.Image != "stalwartlabs/stalwart:v0.15.5" {
t.Errorf("Image = %q", facts.Image)
}
if facts.ComposeProject() != "mail" {
t.Errorf("ComposeProject() = %q, want mail", facts.ComposeProject())
}
if !facts.Running {
t.Error("Running = false, want true")
}
}
// A tag and the digest actually running can disagree - :latest is the
// obvious way, but any moved tag does it. Both are reported because only
// one of them says what is really running.
func TestInspectContainerKeepsTagAndDigestApart(t *testing.T) {
fakeInspect(t, inspectDoc(t, nil, nil))
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatal(err)
}
if facts.ImageID == facts.Image {
t.Error("ImageID and Image should be distinct - one is a tag, the other a digest")
}
if got := shortID(facts.ImageID); got != "0123456789ab" {
t.Errorf("shortID = %q, want 0123456789ab", got)
}
}
func TestMountForPrefersTheMostSpecificMount(t *testing.T) {
facts := ContainerFacts{Mounts: []Mount{
{Type: "bind", Source: "/srv", Destination: "/var/lib", RW: true},
{Type: "volume", Name: "data", Destination: "/var/lib/stalwart/data", RW: true},
}}
m, ok := facts.MountFor("/var/lib/stalwart/data/db")
if !ok {
t.Fatal("MountFor found nothing for a path under a mount")
}
if m.Name != "data" {
t.Errorf("MountFor returned %q, want the more specific 'data' mount", m.Name)
}
if _, ok := facts.MountFor("/etc/stalwart"); ok {
t.Error("MountFor matched a path no mount covers")
}
}
// containerReport runs preflight against a fake container.
func containerReport(t *testing.T, doc string, dataDir string, advisory bool) Report {
t.Helper()
for _, p := range systemdUnitPaths {
if _, err := os.Stat(p); err == nil {
t.Skipf("host has %s, which detection prefers over docker", p)
}
}
fakeInspect(t, doc)
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
if dataDir == "" {
dataDir = t.TempDir()
}
report, err := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
TargetVersion: "latest", ToolCheckAdvisory: true, DeploymentCheckAdvisory: advisory,
}).Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
return report
}
func resultFor(t *testing.T, r Report, name string) CheckResult {
t.Helper()
for _, res := range r.Results {
if res.Name == name {
return res
}
}
t.Fatalf("no %q result in report:\n%s", name, r.String())
return CheckResult{}
}
// A compose-managed container must be refused even once container cutover
// exists: recreating it desyncs the running container from the compose
// file, and the next `compose up` reverts the migration.
func TestComposeManagedContainerIsRefused(t *testing.T) {
doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")})
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime")
if res.Status != StatusFail {
t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
}
}
func TestPlainContainerPassesTheRuntimeCheck(t *testing.T) {
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime")
if res.Status != StatusOK {
t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusOK, res.Detail)
}
}
// Data in the container's own writable layer does not survive the container
// being replaced, and replacing it is what migrating it means.
func TestContainerWithNoWritableMountIsRefused(t *testing.T) {
doc := inspectDoc(t, nil, nil)
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-data-volume")
if res.Status != StatusFail {
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
}
}
// Mounts existing is not the same as the data being on one.
func TestDataDirOutsideEveryMountIsRefused(t *testing.T) {
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
res := resultFor(t, containerReport(t, doc, "/var/lib/stalwart", false), "container-data-volume")
if res.Status != StatusFail {
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
}
}
func TestDataDirOnAVolumePasses(t *testing.T) {
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
res := resultFor(t, containerReport(t, doc, "/opt/stalwart/data", false), "container-data-volume")
if res.Status != StatusOK {
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusOK, res.Detail)
}
}
// rehearse has to keep working against a container it cannot migrate -
// that is when its report is most useful - so the same findings are
// advisory there.
func TestRehearseReportsContainerProblemsWithoutBlocking(t *testing.T) {
doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, nil)
// A real directory, because disk-space stats DataDir on the host. That
// a container-internal path breaks host-side checks is true and is
// PR 3's problem (path translation); it is not what this is testing.
report := containerReport(t, doc, t.TempDir(), true)
if report.Blocking() {
t.Fatalf("advisory mode should not block:\n%s", report.String())
}
for _, name := range []string{"container-runtime", "container-data-volume"} {
if got := resultFor(t, report, name).Status; got != StatusWarn {
t.Errorf("%s = %q, want %q in advisory mode", name, got, StatusWarn)
}
}
}
// docker reports Config.User, Cmd and Entrypoint whether the operator set
// them or the image did. A container off the official image reports user
// "stalwart" having been given no --user, and reading that as an operator
// override made this tool refuse to recreate every ordinary Stalwart
// container - at cutover, with the mail already down. Found while checking
// @kaya-eu's field report against a real image.
func TestInspectContainerIgnoresWhatItInheritedFromItsImage(t *testing.T) {
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")}))
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if facts.User != "" {
t.Errorf("User = %q, want empty: it is the image's own USER, not an override", facts.User)
}
if len(facts.Cmd) != 0 {
t.Errorf("Cmd = %v, want none: it is the image's own CMD", facts.Cmd)
}
if len(facts.Entrypoint) != 0 {
t.Errorf("Entrypoint = %v, want none: it is the image's own ENTRYPOINT", facts.Entrypoint)
}
if len(facts.Unsupported) != 0 {
t.Errorf("Unsupported = %v, want none for a plain container off the official image", facts.Unsupported)
}
}
// The other half of the same distinction: what the operator really did
// override has to be visible, because a recreate that drops it starts
// cleanly as a different server.
func TestInspectContainerReportsWhatTheOperatorOverrode(t *testing.T) {
doc := inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")})
doc = strings.Replace(doc, `"User":"stalwart"`, `"User":"1500:1500"`, 1)
doc = strings.Replace(doc, `"Cmd":["--config","/etc/stalwart/config.json"]`, `"Cmd":["--config","/srv/mine.toml"]`, 1)
if strings.Contains(doc, `"User":"stalwart"`) || strings.Contains(doc, "/etc/stalwart/config.json") {
t.Fatal("the fixture did not take the overrides; the inspect document shape changed")
}
fakeInspect(t, doc)
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if facts.User != "1500:1500" {
t.Errorf("User = %q, want the overridden 1500:1500", facts.User)
}
if strings.Join(facts.Cmd, " ") != "--config /srv/mine.toml" {
t.Errorf("Cmd = %v, want the overridden command", facts.Cmd)
}
// An entrypoint it did not override still reads as inherited.
if len(facts.Entrypoint) != 0 {
t.Errorf("Entrypoint = %v, want none", facts.Entrypoint)
}
}
// Without the image's defaults there is no way to tell an override from an
// inheritance, and guessing decides what a recreate carries. Same rule as
// a failed container inspect: an error, not an assumption.
func TestInspectContainerRefusesWhenTheImageCannotBeRead(t *testing.T) {
fakeInspectOn(t, inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")}), "")
if _, err := InspectContainer(context.Background(), "stalwart"); err == nil {
t.Fatal("want an error when the image's defaults cannot be read")
}
}
// A container-only host has no Stalwart binary, so reading the running
// version from --binary failed preflight's very first check and nothing
// downstream ever ran. What is running is a property of the container's
// image.
func TestPreflightReadsTheSourceVersionFromTheContainersImage(t *testing.T) {
report := dockerPreflightOn(t, false, nil)
for _, res := range report.Results {
if res.Name != "version" {
continue
}
if res.Status != StatusOK {
t.Fatalf("version status = %q, want %q: %s", res.Status, StatusOK, res.Detail)
}
if !strings.Contains(res.Detail, "0.15.5") {
t.Errorf("version detail should report the image's version, got %q", res.Detail)
}
if !strings.Contains(res.Detail, "image behind container") {
t.Errorf("version detail should say where it read the version, got %q", res.Detail)
}
return
}
t.Fatalf("no version result in report:\n%s", report.String())
}
// Asking the image by ID rather than by the tag the container was started
// from: a moved tag would report a version nothing is running.
func TestDetectContainerVersionAsksTheImageTheContainerIsOn(t *testing.T) {
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")}))
got, err := DetectContainerVersion(context.Background(), "stalwart")
if err != nil {
t.Fatalf("DetectContainerVersion: %v", err)
}
if got != "0.15.5" {
t.Errorf("DetectContainerVersion = %q, want 0.15.5", got)
}
}