Initial commit: stalwart-migrator design and scaffolding

In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
This commit is contained in:
2026-08-22 18:17:17 -07:00
commit 719a945d64
71 changed files with 6677 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
package validate
import (
"context"
"fmt"
"net/http"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/recovery"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// BootCheckOptions configures a normal (non-recovery-mode) boot of the
// migrated instance, to confirm it comes up cleanly outside recovery mode -
// not just that recovery mode itself could apply settings to it - and,
// optionally, a content-integrity comparison performed against that same
// boot before it's stopped again.
type BootCheckOptions struct {
BinaryPath string
ConfigPath string
ListenURL string
ExtraEnv []string
Timeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
// ContentIntegrityBefore, if non-nil, is the pre-migration snapshot
// preflight captured (checkpoint.RunState.PreflightSnapshot). When set,
// BootCheck captures a fresh snapshot from the instance it just booted
// - authenticating with AdminUser/AdminPassword, which migrate over
// unchanged with the account (they don't need to differ from the
// pre-migration admin credentials) - and compares the two: this is the
// actual no-data-loss guarantee from ARCHITECTURE.md §4.7, not just
// "the migration mechanics ran". Left nil, only the boot-reachability
// check runs, e.g. when preflight never captured a snapshot because
// --admin-url wasn't set.
ContentIntegrityBefore *checkpoint.PreflightSnapshot
AdminUser string
AdminPassword string
}
// BootCheck starts the target binary the way cutover eventually will (an
// ordinary boot, no STALWART_RECOVERY_MODE), waits for its HTTP listener to
// answer, optionally compares its content against ContentIntegrityBefore
// while it's up, then stops it. It reuses recovery.Process and
// recovery.WaitForHealthy rather than re-implementing process supervision,
// since "start the binary and confirm it's reachable" is exactly what those
// already do.
//
// Like recovery.Run, this is deliberately one atomic operation rather than
// separately checkpointed sub-steps: if this tool's own process crashes
// between the boot succeeding and the content check running, there's no
// safe way to reattach to whatever's left of the child process on resume,
// so a retry just redoes the whole cycle - see recovery.Run's doc comment
// for the full reasoning, which applies identically here.
func BootCheck(ctx context.Context, o BootCheckOptions) (detail string, result *ContentIntegrityResult, err error) {
proc := &recovery.Process{}
if startErr := proc.Start(ctx, recovery.ProcessOptions{
BinaryPath: o.BinaryPath, ConfigPath: o.ConfigPath, RecoveryMode: false, ExtraEnv: o.ExtraEnv,
}); startErr != nil {
return "", nil, fmt.Errorf("validate: start normal boot: %w", startErr)
}
stopGrace := o.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
timeout := o.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
if healthErr := recovery.WaitForHealthy(ctx, o.HTTPClient, o.ListenURL, timeout); healthErr != nil {
return "", nil, fmt.Errorf("migrated instance did not come up under a normal (non-recovery-mode) boot: %w", healthErr)
}
detail = fmt.Sprintf("migrated instance booted normally (not in recovery mode) and answered at %s", o.ListenURL)
if o.ContentIntegrityBefore == nil {
return detail, nil, nil
}
client := &stalwartapi.Client{BaseURL: o.ListenURL, Username: o.AdminUser, Password: o.AdminPassword, HTTPClient: o.HTTPClient}
result, ciErr := compareContentIntegrity(ctx, client, o.ContentIntegrityBefore)
if ciErr != nil {
return detail, nil, fmt.Errorf("content-integrity comparison failed: %w", ciErr)
}
if !result.OK() {
return detail, result, fmt.Errorf("content integrity check found problems: %s", result.String())
}
return detail, result, nil
}
// Run executes BootCheck as a single checkpointed step, mirroring
// preflight/backup/recovery's pattern.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts BootCheckOptions) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseValidate, "boot-check", func() (checkpoint.StepOutcome, error) {
detail, result, err := BootCheck(ctx, opts)
if err != nil {
return checkpoint.StepOutcome{}, err
}
if result != nil {
detail += " - " + result.String()
}
return checkpoint.StepOutcome{Detail: detail}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
+251
View File
@@ -0,0 +1,251 @@
package validate
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestBootCheckSucceedsWhenInstanceComesUp(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if detail == "" {
t.Error("BootCheck returned an empty detail on success")
}
if result != nil {
t.Errorf("result = %+v, want nil when ContentIntegrityBefore wasn't set", result)
}
}
func TestBootCheckFailsWhenInstanceNeverComesUp(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
binPath := filepath.Join(t.TempDir(), "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
_, _, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
Timeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
})
if err == nil {
t.Fatal("BootCheck should fail when nothing ever answers ListenURL")
}
}
func TestRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected failure: %s", report.String())
}
// Resume with a config that would fail if re-executed (nothing listens
// on badPort) - a skip proves the step didn't re-run.
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.Timeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the check: %v", err)
}
if report2.Blocking() {
t.Fatalf("Run #2 (resume): unexpected failure: %s", report2.String())
}
}
// beforeSnapshotWithAliceInbox builds a checkpoint.PreflightSnapshot
// matching the fake server's single hardcoded account ([email protected],
// mailbox "Inbox") with the given pre-migration message count.
func beforeSnapshotWithAliceInbox(messages int) *checkpoint.PreflightSnapshot {
return &checkpoint.PreflightSnapshot{
AccountCount: 1,
Domains: []string{"example.com"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: messages}},
},
}
}
func TestBootCheckContentIntegrityPassesWhenCountsMatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42", // matches beforeSnapshotWithAliceInbox(42)
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42),
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if result == nil {
t.Fatal("result should be populated when ContentIntegrityBefore was set")
}
if !result.OK() {
t.Errorf("result.OK() = false, want true: %s", result.String())
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 1 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 1", result.AccountsChecked, result.MailboxesChecked)
}
if detail == "" {
t.Error("detail should still describe the boot")
}
}
func TestBootCheckContentIntegrityFailsWhenCountsMismatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=40", // the "after" server reports 40
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42), // but "before" said 42 - two messages went missing
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when a post-migration mailbox count doesn't match the pre-migration one")
}
if result == nil || result.OK() {
t.Fatalf("result = %+v, want a non-OK result describing the mismatch", result)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one entry", result.MessageCountMismatches)
}
mismatch := result.MessageCountMismatches[0]
if mismatch.Account != "[email protected]" || mismatch.Mailbox != "Inbox" || mismatch.Before != 42 || mismatch.After != 40 {
t.Errorf("mismatch = %+v, want [email protected]/Inbox 42->40", mismatch)
}
}
func TestBootCheckContentIntegrityDetectsMissingAccount(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
before := &checkpoint.PreflightSnapshot{
AccountCount: 2,
Domains: []string{"example.com", "example.net"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: 42}},
"[email protected]": {{Mailbox: "Inbox", Messages: 5}}, // the fake server only ever knows about alice
},
}
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42",
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: before,
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when an account present before migration can't be found afterward")
}
if result == nil || len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "[email protected]" {
t.Fatalf("result = %+v, want MissingAccounts = [[email protected]]", result)
}
}
+113
View File
@@ -0,0 +1,113 @@
package validate
import (
"context"
"fmt"
"sort"
"strings"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// MailboxDelta is one mailbox whose message count didn't match between the
// pre- and post-migration snapshots.
type MailboxDelta struct {
Account string
Mailbox string
Before int
After int
}
// ContentIntegrityResult is the outcome of comparing a pre-migration
// snapshot against a freshly captured post-migration one - the actual
// no-data-loss check described in ARCHITECTURE.md §4.7.
type ContentIntegrityResult struct {
AccountsChecked int
MailboxesChecked int
MissingAccounts []string // present before, not found after (even accounting for the email-address rewrite)
MessageCountMismatches []MailboxDelta // present both before and after, but with a different message count
}
// OK reports whether every account and mailbox the pre-migration snapshot
// knew about was found afterward with an identical message count.
func (r ContentIntegrityResult) OK() bool {
return len(r.MissingAccounts) == 0 && len(r.MessageCountMismatches) == 0
}
func (r ContentIntegrityResult) String() string {
if r.OK() {
return fmt.Sprintf("content integrity: %d account(s), %d mailbox(es) checked, all message counts match", r.AccountsChecked, r.MailboxesChecked)
}
var b strings.Builder
fmt.Fprintf(&b, "content integrity: %d account(s), %d mailbox(es) checked", r.AccountsChecked, r.MailboxesChecked)
for _, a := range r.MissingAccounts {
fmt.Fprintf(&b, "; MISSING ACCOUNT %s", a)
}
for _, d := range r.MessageCountMismatches {
fmt.Fprintf(&b, "; MESSAGE COUNT MISMATCH %s/%s: %d before, %d after", d.Account, d.Mailbox, d.Before, d.After)
}
return b.String()
}
// compareContentIntegrity captures a fresh snapshot via client and compares
// it against before, matching accounts by exact name first and falling
// back to the local part (the text before "@") since Stalwart's v0.16
// migration rewrites bare usernames to full email addresses
// (UPGRADING/v0_16.md: "the migration script automatically assigns the
// default domain to accounts lacking one") - an exact-string comparison
// alone would misreport every rewritten account as missing.
func compareContentIntegrity(ctx context.Context, client *stalwartapi.Client, before *checkpoint.PreflightSnapshot) (*ContentIntegrityResult, error) {
after, err := client.AccountSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("capture post-migration snapshot: %w", err)
}
result := &ContentIntegrityResult{}
beforeAccounts := make([]string, 0, len(before.MailboxCounts))
for a := range before.MailboxCounts {
beforeAccounts = append(beforeAccounts, a)
}
sort.Strings(beforeAccounts)
for _, beforeAccount := range beforeAccounts {
result.AccountsChecked++
afterMailboxes, found := after.MailboxCounts[beforeAccount]
if !found {
afterMailboxes, found = findByLocalPart(after.MailboxCounts, beforeAccount)
}
if !found {
result.MissingAccounts = append(result.MissingAccounts, beforeAccount)
continue
}
afterByName := make(map[string]int, len(afterMailboxes))
for _, m := range afterMailboxes {
afterByName[m.Mailbox] = m.Messages
}
beforeMailboxes := append([]checkpoint.MailboxCount(nil), before.MailboxCounts[beforeAccount]...)
sort.Slice(beforeMailboxes, func(i, j int) bool { return beforeMailboxes[i].Mailbox < beforeMailboxes[j].Mailbox })
for _, bm := range beforeMailboxes {
result.MailboxesChecked++
afterCount, ok := afterByName[bm.Mailbox]
if !ok || afterCount != bm.Messages {
result.MessageCountMismatches = append(result.MessageCountMismatches, MailboxDelta{
Account: beforeAccount, Mailbox: bm.Mailbox, Before: bm.Messages, After: afterCount,
})
}
}
}
return result, nil
}
func findByLocalPart(mailboxCounts map[string][]stalwartapi.MailboxCount, beforeAccount string) ([]stalwartapi.MailboxCount, bool) {
local := strings.SplitN(beforeAccount, "@", 2)[0]
for afterAccount, mb := range mailboxCounts {
if strings.SplitN(afterAccount, "@", 2)[0] == local {
return mb, true
}
}
return nil, false
}
+165
View File
@@ -0,0 +1,165 @@
package validate
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
)
// jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses.
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// fakeManagementServer serves x:Account/query + x:Account/get from
// accounts, and, for each of them, session discovery + Mailbox/get from
// mailboxesByEmail (keyed by the account's post-migration email).
func fakeManagementServer(t *testing.T, accounts []map[string]any, mailboxesByEmail map[string][]map[string]any) *httptest.Server {
t.Helper()
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesByEmail[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
ids := make([]string, len(accounts))
for i, a := range accounts {
ids[i] = a["id"].(string)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": ids}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": accounts}, "g"},
}})
case "Mailbox/get":
args := call[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesByEmail[target]}, "m"},
}})
}
}))
apiURL = srv.URL + "/api"
return srv
}
func TestCompareContentIntegrityMatchesRewrittenBareUsernameByLocalPart(t *testing.T) {
// Pre-migration, the account was a bare username "alice" (pre-0.16
// style). Post-migration, v0.16's own conversion rewrote it to a full
// email address - see UPGRADING/v0_16.md. An exact-string match would
// wrongly report "alice" as missing.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 42}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}}, // bare username, pre-migration
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if !result.OK() {
t.Errorf("result.OK() = false, want true (local-part match should have found [email protected]): %s", result.String())
}
if len(result.MissingAccounts) != 0 {
t.Errorf("MissingAccounts = %v, want none", result.MissingAccounts)
}
}
func TestCompareContentIntegrityNoFalseMatchAcrossUnrelatedAccounts(t *testing.T) {
// "alice" (before) must not spuriously match "[email protected]"
// (after) just because one contains the other - local-part comparison
// must be an exact match on the part before "@", not a substring check.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 1}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.OK() {
t.Fatal("result.OK() = true, want a missing-account failure - [email protected] is a different account than alice")
}
if len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "alice" {
t.Errorf("MissingAccounts = %v, want [alice]", result.MissingAccounts)
}
}
func TestCompareContentIntegrityMultipleMailboxesPerAccount(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.org"}},
map[string][]map[string]any{"[email protected]": {
{"name": "Inbox", "totalEmails": 10},
{"name": "Archive", "totalEmails": 200},
}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {
{Mailbox: "Inbox", Messages: 10},
{Mailbox: "Archive", Messages: 199}, // one message short
},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 2 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 2", result.AccountsChecked, result.MailboxesChecked)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one (Archive)", result.MessageCountMismatches)
}
m := result.MessageCountMismatches[0]
if m.Mailbox != "Archive" || m.Before != 199 || m.After != 200 {
t.Errorf("mismatch = %+v, want Archive 199->200", m)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package validate implements the post-migration validation suite and its structured report.
// See ARCHITECTURE.md §4.7 for the design.
package validate
+101
View File
@@ -0,0 +1,101 @@
package validate
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"testing"
)
// TestMain lets this test binary also act as a fake Stalwart binary,
// mirroring internal/recovery's own TestMain - see that package's doc
// comment for why (the standard os/exec "helper process" technique). Beyond
// plain reachability, it also speaks just enough JMAP to serve
// stalwartapi.Client.AccountSnapshot (x:Account/query, x:Account/get,
// session discovery, Mailbox/get) for a single fixed fake account
// "[email protected]", so BootCheck's content-integrity comparison can be
// exercised against a real subprocess rather than mocked in-process. The
// mailbox message count it reports is configurable via
// STALWART_MIGRATOR_TEST_MAILBOX_COUNT (default 42), so tests can produce
// both a matching and a mismatching post-migration snapshot.
func TestMain(m *testing.M) {
if os.Getenv("STALWART_MIGRATOR_TEST_HELPER") == "1" {
runFakeStalwartServer()
return
}
os.Exit(m.Run())
}
func runFakeStalwartServer() {
port := os.Getenv("STALWART_MIGRATOR_TEST_PORT")
messageCount := 42
if v := os.Getenv("STALWART_MIGRATOR_TEST_MAILBOX_COUNT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
messageCount = n
}
}
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
fmt.Fprintln(os.Stderr, "fake stalwart: listen:", err)
os.Exit(1)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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": "http://127.0.0.1:" + port + "/api",
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls, _ := body["methodCalls"].([]any)
if len(methodCalls) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
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": messageCount},
}}, "m"},
}})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusOK)
}
})}
go srv.Serve(ln)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
os.Exit(0)
}
+40
View File
@@ -0,0 +1,40 @@
package validate
import (
"fmt"
"strings"
)
type Status string
const (
StatusOK Status = "ok"
StatusFail Status = "fail"
)
type CheckResult struct {
Name string
Status Status
Detail string
}
type Report struct {
Results []CheckResult
}
func (r Report) Blocking() bool {
for _, res := range r.Results {
if res.Status == StatusFail {
return true
}
}
return false
}
func (r Report) String() string {
var b strings.Builder
for _, res := range r.Results {
fmt.Fprintf(&b, "[%-4s] %-16s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
}
return b.String()
}