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.
380 lines
14 KiB
Go
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/Coffey-Labs/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)
|
|
}
|
|
}
|