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.
610 lines
22 KiB
Go
610 lines
22 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package preflight
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
|
|
)
|
|
|
|
// writeFakeBinary creates a shell script that behaves like `stalwart
|
|
// --version` and records each invocation to counterPath, so tests can
|
|
// assert a checkpointed step was (or wasn't) re-executed on resume.
|
|
func writeFakeBinary(t *testing.T, version, counterPath string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
scriptPath := filepath.Join(dir, "fake-stalwart.sh")
|
|
script := fmt.Sprintf("#!/bin/sh\necho invoked >> %q\necho 'stalwart %s'\n", counterPath, version)
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return scriptPath
|
|
}
|
|
|
|
func countLines(t *testing.T, path string) int {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return 0
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(strings.Split(strings.TrimSpace(string(data)), "\n"))
|
|
}
|
|
|
|
func TestCheckerRunEndToEndAndResume(t *testing.T) {
|
|
// preflight now verifies the external tools before anything else; give
|
|
// this environment ones that satisfy it.
|
|
fakeTool(t, "stalwart-cli", "stalwart-cli 1.0.12")
|
|
fakeTool(t, "python3", "Python 3.13.5")
|
|
// -- fixtures --------------------------------------------------------
|
|
counterPath := filepath.Join(t.TempDir(), "invocations")
|
|
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.toml")
|
|
tomlCfg := "[store.\"rocksdb\"]\ntype = \"rocksdb\"\npath = \"/var/lib/stalwart/data\"\n"
|
|
if err := os.WriteFile(configPath, []byte(tomlCfg), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dataDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(Release{
|
|
TagName: "v0.16.14",
|
|
Assets: []ReleaseAsset{{Name: "checksums.txt"}},
|
|
})
|
|
})
|
|
|
|
stateDir := t.TempDir()
|
|
store := checkpoint.NewStore(stateDir)
|
|
rs, err := store.Create("", "latest")
|
|
if err != nil {
|
|
t.Fatalf("store.Create: %v", err)
|
|
}
|
|
|
|
checker := New(Options{
|
|
BinaryPath: binaryPath,
|
|
ConfigPath: configPath,
|
|
DataDir: dataDir,
|
|
TargetVersion: "latest",
|
|
})
|
|
|
|
// -- first run: everything should execute -----------------------------
|
|
report, err := checker.Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("Run #1: %v", err)
|
|
}
|
|
if report.Blocking() {
|
|
t.Fatalf("Run #1: unexpected blocking report:\n%s", report.String())
|
|
}
|
|
if got := countLines(t, counterPath); got != 1 {
|
|
t.Fatalf("binary invocations after Run #1 = %d, want 1", got)
|
|
}
|
|
if rs.SourceVersion != "0.15.5" {
|
|
t.Errorf("rs.SourceVersion = %q, want 0.15.5", rs.SourceVersion)
|
|
}
|
|
if rs.TargetVersion != "0.16.14" {
|
|
t.Errorf("rs.TargetVersion = %q, want 0.16.14 (resolved from \"latest\")", rs.TargetVersion)
|
|
}
|
|
if rs.Topology.StoreBackend != "rocksdb" {
|
|
t.Errorf("rs.Topology.StoreBackend = %q, want rocksdb", rs.Topology.StoreBackend)
|
|
}
|
|
|
|
foundDirection := false
|
|
for _, res := range report.Results {
|
|
if res.Name == "upgrade-direction" {
|
|
foundDirection = true
|
|
if !strings.Contains(res.Detail, "major boundary") {
|
|
t.Errorf("upgrade-direction detail = %q, want mention of the major boundary", res.Detail)
|
|
}
|
|
}
|
|
}
|
|
if !foundDirection {
|
|
t.Error("report missing upgrade-direction check")
|
|
}
|
|
|
|
// -- simulate a crash and resume: reload state from disk fresh --------
|
|
resumed, err := store.Load(rs.RunID)
|
|
if err != nil {
|
|
t.Fatalf("store.Load (resume): %v", err)
|
|
}
|
|
report2, err := checker.Run(context.Background(), store, resumed)
|
|
if err != nil {
|
|
t.Fatalf("Run #2 (resume): %v", err)
|
|
}
|
|
if got := countLines(t, counterPath); got != 1 {
|
|
t.Errorf("binary invocations after resumed Run = %d, want 1 (already-done steps must not re-execute)", got)
|
|
}
|
|
if len(report2.Results) != len(report.Results) {
|
|
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
|
|
}
|
|
}
|
|
|
|
func TestCheckerRunFlagsTooOldSource(t *testing.T) {
|
|
counterPath := filepath.Join(t.TempDir(), "invocations")
|
|
binaryPath := writeFakeBinary(t, "0.14.2", 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)
|
|
}
|
|
dataDir := t.TempDir()
|
|
|
|
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)
|
|
}
|
|
checker := New(Options{BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest"})
|
|
|
|
report, err := checker.Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !report.Blocking() {
|
|
t.Fatalf("expected a blocking report for a too-old source version, got:\n%s", report.String())
|
|
}
|
|
}
|
|
|
|
func TestCheckerRunFlagsInsufficientDiskSpace(t *testing.T) {
|
|
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("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dataDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 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)
|
|
}
|
|
// An absurd multiple guarantees the free-space check fails regardless
|
|
// of how much space the test host actually has free.
|
|
checker := New(Options{
|
|
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
|
|
TargetVersion: "latest", MinFreeMultiple: 1e12,
|
|
})
|
|
|
|
report, err := checker.Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !report.Blocking() {
|
|
t.Fatalf("expected a blocking report for insufficient disk space, got:\n%s", report.String())
|
|
}
|
|
}
|
|
|
|
func TestCheckerRunCapturesAccountSnapshotWhenAdminURLSet(t *testing.T) {
|
|
// preflight now verifies the external tools before anything else; give
|
|
// this environment ones that satisfy it.
|
|
fakeTool(t, "stalwart-cli", "stalwart-cli 1.0.12")
|
|
fakeTool(t, "python3", "Python 3.13.5")
|
|
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("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dataDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
|
|
})
|
|
|
|
// A fake admin server: answers Ping's session-discovery GET, the
|
|
// impersonated session-discovery GET MailboxSnapshot makes for
|
|
// [email protected], and the POST /api calls for x:Account/query,
|
|
// x:Account/get, and Mailbox/get.
|
|
var apiURL string
|
|
adminSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/principal" {
|
|
w.WriteHeader(http.StatusNotFound) // v0.16 shape: no REST management API
|
|
return
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
|
|
user, _, _ := r.BasicAuth()
|
|
if strings.Contains(user, "%") {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"apiUrl": apiURL,
|
|
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
|
|
})
|
|
return
|
|
}
|
|
// A 0.16-era instance: the urn:stalwart:jmap capability is what
|
|
// says its management API is the JMAP one.
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"apiUrl": apiURL,
|
|
"capabilities": map[string]any{"urn:ietf:params:jmap:core": map[string]any{}, "urn:stalwart:jmap": map[string]any{}},
|
|
})
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api":
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
methodCalls := body["methodCalls"].([]any)
|
|
name := methodCalls[0].([]any)[0].(string)
|
|
switch name {
|
|
case "x:Domain/query":
|
|
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
|
[]any{"x:Domain/query", map[string]any{"ids": []string{"d1"}}, "q"},
|
|
[]any{"x:Domain/get", map[string]any{"list": []map[string]any{
|
|
{"id": "d1", "name": "example.com"},
|
|
}}, "g"},
|
|
}})
|
|
case "x:Account/query":
|
|
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
|
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
|
|
}})
|
|
case "x:Account/get":
|
|
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
|
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
|
|
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
|
|
}}, "g"},
|
|
}})
|
|
case "Mailbox/get":
|
|
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
|
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
|
|
{"name": "Inbox", "totalEmails": 5},
|
|
}}, "m"},
|
|
}})
|
|
}
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
apiURL = adminSrv.URL + "/api"
|
|
defer adminSrv.Close()
|
|
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("", "latest")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checker := New(Options{
|
|
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest",
|
|
// A directory account, not a config fallback-admin: preflight now
|
|
// refuses the latter, since it does not survive into v0.16.
|
|
AdminURL: adminSrv.URL, AdminUser: "[email protected]", AdminPassword: "hunter2",
|
|
})
|
|
|
|
report, err := checker.Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if report.Blocking() {
|
|
t.Fatalf("unexpected blocking report:\n%s", report.String())
|
|
}
|
|
if rs.PreflightSnapshot == nil {
|
|
t.Fatal("PreflightSnapshot should be populated when AdminURL is set")
|
|
}
|
|
if rs.PreflightSnapshot.AccountCount != 1 {
|
|
t.Errorf("AccountCount = %d, want 1", rs.PreflightSnapshot.AccountCount)
|
|
}
|
|
if len(rs.PreflightSnapshot.Domains) != 1 || rs.PreflightSnapshot.Domains[0] != "example.com" {
|
|
t.Errorf("Domains = %v, want [example.com]", rs.PreflightSnapshot.Domains)
|
|
}
|
|
aliceMailboxes := rs.PreflightSnapshot.MailboxCounts["[email protected]"]
|
|
if len(aliceMailboxes) != 1 || aliceMailboxes[0].Mailbox != "Inbox" || aliceMailboxes[0].Messages != 5 {
|
|
t.Errorf("alice's mailbox counts = %+v, want [{Inbox 5}]", aliceMailboxes)
|
|
}
|
|
|
|
// Resume: the snapshot must survive a reload from disk without
|
|
// re-running the check (the admin server would still work, but this
|
|
// confirms the persisted value is what's actually being relied on).
|
|
resumed, err := store.Load(rs.RunID)
|
|
if err != nil {
|
|
t.Fatalf("store.Load: %v", err)
|
|
}
|
|
if resumed.PreflightSnapshot == nil || resumed.PreflightSnapshot.AccountCount != 1 {
|
|
t.Errorf("resumed PreflightSnapshot = %+v, want AccountCount 1", resumed.PreflightSnapshot)
|
|
}
|
|
}
|
|
|
|
// A host with no route to the internet cannot reach the release API, and
|
|
// failing there would stop it migrating even though the binary it needs is
|
|
// already on disk. The binary itself answers the question.
|
|
func TestTargetReleaseReadsALocalBinaryInsteadOfTheAPI(t *testing.T) {
|
|
fakeTool(t, "stalwart-cli", "stalwart-cli 1.0.12")
|
|
fakeTool(t, "python3", "Python 3.13.5")
|
|
counter := filepath.Join(t.TempDir(), "invocations")
|
|
current := writeFakeBinary(t, "0.15.5", counter)
|
|
target := writeFakeBinary(t, "0.16.19", counter)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dataDir := t.TempDir()
|
|
|
|
// Point the release API at a listener that fails the test if used.
|
|
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("the release API must not be consulted when a local target binary was given (%s)", r.URL.Path)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("", "0.16.19")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := New(Options{
|
|
BinaryPath: current, ConfigPath: configPath, DataDir: dataDir,
|
|
TargetVersion: "0.16.19", TargetBinaryPath: target, MinFreeMultiple: 0.0001,
|
|
}).Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("preflight: %v", err)
|
|
}
|
|
var found *CheckResult
|
|
for i := range report.Results {
|
|
if report.Results[i].Name == "target-release" {
|
|
found = &report.Results[i]
|
|
}
|
|
}
|
|
if found == nil || found.Status != StatusOK {
|
|
t.Fatalf("target-release = %+v, want OK without touching the network\n%s", found, report.String())
|
|
}
|
|
if !strings.Contains(found.Detail, "0.16.19") {
|
|
t.Fatalf("detail should name the version it read, got %q", found.Detail)
|
|
}
|
|
}
|
|
|
|
func TestTargetReleaseRejectsAMismatchedLocalBinary(t *testing.T) {
|
|
fakeTool(t, "stalwart-cli", "stalwart-cli 1.0.12")
|
|
fakeTool(t, "python3", "Python 3.13.5")
|
|
counter := filepath.Join(t.TempDir(), "invocations")
|
|
configPath := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("", "0.16.19")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := New(Options{
|
|
BinaryPath: writeFakeBinary(t, "0.15.5", counter), ConfigPath: configPath, DataDir: t.TempDir(),
|
|
TargetVersion: "0.16.19", TargetBinaryPath: writeFakeBinary(t, "0.16.14", counter), MinFreeMultiple: 0.0001,
|
|
}).Run(context.Background(), store, rs)
|
|
if err != nil {
|
|
t.Fatalf("preflight: %v", err)
|
|
}
|
|
for _, r := range report.Results {
|
|
if r.Name == "target-release" {
|
|
if r.Status != StatusFail {
|
|
t.Fatalf("target-release = %+v, want FAIL for a binary that is not the target", r)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("no target-release check in the report")
|
|
}
|
|
|
|
// v0.16 keeps its configuration in the store, so a v0.15
|
|
// [authentication.fallback-admin] simply ceases to exist. Authenticating as
|
|
// one passes every check today and is refused the moment the migration
|
|
// finishes - taking the quota rebuild and the post-migration content
|
|
// comparison with it. Observed on a production clone: the run migrated
|
|
// cleanly and then failed all three post-cutover steps with 401.
|
|
func TestAdminAccountKind(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
admin string
|
|
want Status
|
|
}{
|
|
{"a directory account survives", "[email protected]", StatusOK},
|
|
{"its local part is accepted too", "alice", StatusOK},
|
|
{"a config fallback-admin does not", "admin", StatusFail},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rs := &checkpoint.RunState{PreflightSnapshot: &checkpoint.PreflightSnapshot{
|
|
UsedQuota: map[string]int64{"[email protected]": 1, "[email protected]": 2},
|
|
}}
|
|
c := New(Options{AdminUser: tc.admin})
|
|
res := c.adminAccountKind(rs)
|
|
if res.Status != tc.want {
|
|
t.Fatalf("status = %q, want %q (%s)", res.Status, tc.want, res.Detail)
|
|
}
|
|
if tc.want == StatusFail && !strings.Contains(res.Detail, "fallback-admin") {
|
|
t.Fatalf("the operator needs to be told why, got %q", res.Detail)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminAccountKindWithoutASnapshot(t *testing.T) {
|
|
res := New(Options{AdminUser: "admin"}).adminAccountKind(&checkpoint.RunState{})
|
|
if res.Status != StatusWarn {
|
|
t.Fatalf("status = %q, want a warning when there was nothing to look in", res.Status)
|
|
}
|
|
}
|
|
|
|
// withFakeDocker puts a `docker` on PATH that answers `inspect` successfully,
|
|
// so DetectDeploymentKind sees a container without one being involved.
|
|
//
|
|
// It skips rather than fails if this host has a real stalwart systemd unit:
|
|
// detection checks those paths first and would win, and a test that depends
|
|
// on the host *not* having Stalwart installed is a test that fails for a
|
|
// reason unrelated to what it is checking.
|
|
func withFakeDocker(t *testing.T) {
|
|
t.Helper()
|
|
for _, p := range systemdUnitPaths {
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Skipf("host has %s, which detection prefers over docker", p)
|
|
}
|
|
}
|
|
// A healthy plain container: not compose-managed, data on a volume.
|
|
// The container checks inspect it for real now, so an `inspect` that
|
|
// merely exits 0 would fail them for the wrong reason.
|
|
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")}))
|
|
}
|
|
|
|
// dockerPreflight runs a minimal but real preflight against a fake 0.15.5
|
|
// install with a fake docker on PATH.
|
|
func dockerPreflight(t *testing.T, advisory bool) Report {
|
|
t.Helper()
|
|
return dockerPreflightOn(t, advisory, nil)
|
|
}
|
|
|
|
// dockerPreflightOn runs preflight against a container whose inspect
|
|
// document has been rewritten by edit, for the cases that need it to be
|
|
// something other than an ordinary one.
|
|
func dockerPreflightOn(t *testing.T, advisory bool, edit func(string) string) Report {
|
|
t.Helper()
|
|
// disk-space stats DataDir on this host, and container-data-volume
|
|
// wants it covered by a mount, so it has to be both: a real directory,
|
|
// mounted by the fake container.
|
|
dataDir := t.TempDir()
|
|
for _, p := range systemdUnitPaths {
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Skipf("host has %s, which detection prefers over docker", p)
|
|
}
|
|
}
|
|
doc := inspectDoc(t, nil, []Mount{dataVolume(dataDir)})
|
|
if edit != nil {
|
|
doc = edit(doc)
|
|
}
|
|
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)
|
|
}
|
|
// ToolCheckAdvisory is on in both cases so the deployment flag is the
|
|
// only thing that varies: whether stalwart-cli happens to be installed
|
|
// on the machine running the tests is not what this is testing.
|
|
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
|
|
}
|
|
|
|
// Being a container is no longer a refusal on its own - cutover can
|
|
// recreate one now. What refuses is specific to *this* container: compose
|
|
// management, and data that is not on a volume. Those live in
|
|
// container_test.go, and both still block.
|
|
//
|
|
// This previously asserted the blanket refusal. It asserts the replacement
|
|
// rather than being deleted, because "docker is allowed through here" is
|
|
// the thing that would be wrong to regress.
|
|
func TestPreflightAllowsAPlainContainerThroughTheKindCheck(t *testing.T) {
|
|
report := dockerPreflight(t, false)
|
|
if report.Blocking() {
|
|
t.Fatalf("a plain container on a volume should not be blocked:\n%s", report.String())
|
|
}
|
|
var found bool
|
|
for _, res := range report.Results {
|
|
if res.Name == "deployment-kind" {
|
|
found = true
|
|
if res.Status != StatusOK {
|
|
t.Errorf("deployment-kind status = %q, want %q", res.Status, StatusOK)
|
|
}
|
|
if !strings.Contains(res.Detail, "docker") {
|
|
t.Errorf("deployment-kind detail does not mention docker: %q", res.Detail)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("no deployment-kind result in report:\n%s", report.String())
|
|
}
|
|
}
|
|
|
|
// rehearse is read-only: it never stops anything and never cuts over, so it
|
|
// has to keep working against a container. Telling an operator what the
|
|
// migration involves is most useful precisely when the tool cannot do it for
|
|
// them.
|
|
func TestRehearseStillRunsAgainstADockerDeployment(t *testing.T) {
|
|
report := dockerPreflight(t, true)
|
|
if report.Blocking() {
|
|
t.Fatalf("advisory mode should not block on docker, got:\n%s", report.String())
|
|
}
|
|
}
|
|
|
|
// Cutover already refused a container it could not recreate, but cutover
|
|
// is downstream of the stop, the settings conversion and the store
|
|
// migration - so that refusal arrived with the mail down and the data
|
|
// already moved, which is the shape of failure issue #1 was filed for.
|
|
// The answer never changes between the two points, so it is asked here,
|
|
// while the server is still running.
|
|
func TestPreflightRefusesAContainerItCouldNotRecreate(t *testing.T) {
|
|
report := dockerPreflightOn(t, false, func(doc string) string {
|
|
return strings.Replace(doc, `"State":`, `"HostConfig":{"Privileged":true},"State":`, 1)
|
|
})
|
|
if !report.Blocking() {
|
|
t.Fatalf("a container preflight cannot recreate should block before anything stops:\n%s", report.String())
|
|
}
|
|
var found bool
|
|
for _, res := range report.Results {
|
|
if res.Name == "container-recreatable" {
|
|
found = true
|
|
if res.Status != StatusFail {
|
|
t.Errorf("container-recreatable status = %q, want %q", res.Status, StatusFail)
|
|
}
|
|
if !strings.Contains(res.Detail, "privileged") {
|
|
t.Errorf("detail should name what would be dropped, got %q", res.Detail)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("no container-recreatable result in report:\n%s", report.String())
|
|
}
|
|
}
|
|
|
|
// rehearse never stops or recreates anything, so the same finding is a
|
|
// warning there: an operator migrating by hand needs to know it more than
|
|
// an automated run does.
|
|
func TestRehearseWarnsRatherThanBlocksOnAnUnrecreatableContainer(t *testing.T) {
|
|
report := dockerPreflightOn(t, true, func(doc string) string {
|
|
return strings.Replace(doc, `"State":`, `"HostConfig":{"Privileged":true},"State":`, 1)
|
|
})
|
|
if report.Blocking() {
|
|
t.Fatalf("advisory mode should not block, got:\n%s", report.String())
|
|
}
|
|
}
|