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
+3
View File
@@ -0,0 +1,3 @@
// Package checkpoint implements run-id and state.json persistence, resume logic, and rollback-window tracking.
// See ARCHITECTURE.md §5 for the design.
package checkpoint
+88
View File
@@ -0,0 +1,88 @@
package checkpoint
import (
"fmt"
"time"
)
func (rs *RunState) stepIndex(phase Phase, name string) int {
for i := range rs.Steps {
if rs.Steps[i].Phase == phase && rs.Steps[i].Name == name {
return i
}
}
return -1
}
// Status returns the current status of a step, or StepPending if it has
// never been started.
func (rs *RunState) Status(phase Phase, name string) StepStatus {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].Status
}
return StepPending
}
// Done reports whether a step already completed successfully. Callers use
// this to decide whether to skip work on resume.
func (rs *RunState) Done(phase Phase, name string) bool {
return rs.Status(phase, name) == StepDone
}
// Outcome returns the StepOutcome recorded for a step, zero-valued if none.
// This is what lets a resumed run reconstruct a skipped step's result
// without re-executing it.
func (rs *RunState) Outcome(phase Phase, name string) StepOutcome {
if i := rs.stepIndex(phase, name); i >= 0 {
return rs.Steps[i].StepOutcome
}
return StepOutcome{}
}
// Begin marks a step as running, creating its record on first attempt or
// resetting it on a retry after a prior failure.
func (rs *RunState) Begin(phase Phase, name string) {
now := time.Now().UTC()
if i := rs.stepIndex(phase, name); i >= 0 {
rs.Steps[i].Status = StepRunning
rs.Steps[i].StartedAt = &now
rs.Steps[i].CompletedAt = nil
rs.Steps[i].StepOutcome = StepOutcome{}
rs.Steps[i].Error = ""
} else {
rs.Steps = append(rs.Steps, StepRecord{
Phase: phase, Name: name, Status: StepRunning, StartedAt: &now,
})
}
rs.UpdatedAt = now
}
// Complete marks a step done with its outcome. It panics if Begin was never
// called for this step - that's a bug in the calling phase, not a runtime
// condition callers should need to handle.
func (rs *RunState) Complete(phase Phase, name string, outcome StepOutcome) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Complete(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepDone
rs.Steps[i].CompletedAt = &now
rs.Steps[i].StepOutcome = outcome
rs.Steps[i].Error = ""
rs.UpdatedAt = now
}
// Fail marks a step failed, so a later Begin for the same (phase, name)
// knows to retry it rather than treat it as done.
func (rs *RunState) Fail(phase Phase, name string, stepErr error) {
i := rs.stepIndex(phase, name)
if i < 0 {
panic(fmt.Sprintf("checkpoint: Fail(%s/%s) called without Begin", phase, name))
}
now := time.Now().UTC()
rs.Steps[i].Status = StepFailed
rs.Steps[i].CompletedAt = &now
rs.Steps[i].Error = stepErr.Error()
rs.UpdatedAt = now
}
+179
View File
@@ -0,0 +1,179 @@
package checkpoint
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// DefaultBaseDir is where runs are persisted when the operator doesn't
// override it.
const DefaultBaseDir = "/var/lib/stalwart-migrator/runs"
// Store persists RunState to disk. It's the only thing in this package that
// touches the filesystem - RunState itself is a plain data type.
type Store struct {
baseDir string
}
func NewStore(baseDir string) *Store {
if baseDir == "" {
baseDir = DefaultBaseDir
}
return &Store{baseDir: baseDir}
}
func (s *Store) runDir(runID string) string { return filepath.Join(s.baseDir, runID) }
func (s *Store) statePath(runID string) string { return filepath.Join(s.runDir(runID), "state.json") }
// Create starts a new run, assigns it an ID, and persists its initial
// state before returning it.
func (s *Store) Create(sourceVersion, targetVersion string) (*RunState, error) {
id, err := newRunID()
if err != nil {
return nil, fmt.Errorf("checkpoint: generate run id: %w", err)
}
now := time.Now().UTC()
rs := &RunState{
RunID: id,
SourceVersion: sourceVersion,
TargetVersion: targetVersion,
CreatedAt: now,
UpdatedAt: now,
Artifacts: map[string]Artifact{},
}
if err := s.Save(rs); err != nil {
return nil, err
}
return rs, nil
}
// Load reads an existing run's state from disk.
func (s *Store) Load(runID string) (*RunState, error) {
data, err := os.ReadFile(s.statePath(runID))
if err != nil {
return nil, fmt.Errorf("checkpoint: load run %s: %w", runID, err)
}
var rs RunState
if err := json.Unmarshal(data, &rs); err != nil {
return nil, fmt.Errorf("checkpoint: parse run %s: %w", runID, err)
}
return &rs, nil
}
// Save writes state to disk atomically: write to a temp file in the same
// directory, fsync it, then rename over the real path. A crash mid-write
// leaves the temp file orphaned and state.json untouched, never a
// truncated or corrupt state.json - that file is the one thing every phase
// and a human operator both trust as the source of truth for what's
// already happened, so a half-written version of it would be worse than an
// old one.
func (s *Store) Save(rs *RunState) error {
dir := s.runDir(rs.RunID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("checkpoint: create run directory: %w", err)
}
data, err := json.MarshalIndent(rs, "", " ")
if err != nil {
return fmt.Errorf("checkpoint: marshal state: %w", err)
}
tmp, err := os.CreateTemp(dir, "state-*.json.tmp")
if err != nil {
return fmt.Errorf("checkpoint: create temp state file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: write temp state file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: sync temp state file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: close temp state file: %w", err)
}
if err := os.Rename(tmpPath, s.statePath(rs.RunID)); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("checkpoint: rename temp state file into place: %w", err)
}
return nil
}
// List returns known run IDs, most recently created first.
func (s *Store) List() ([]string, error) {
entries, err := os.ReadDir(s.baseDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("checkpoint: list runs: %w", err)
}
type run struct {
id string
created time.Time
}
var runs []run
for _, e := range entries {
if !e.IsDir() {
continue
}
rs, err := s.Load(e.Name())
if err != nil {
continue // not a run directory (or a corrupt one) - skip it
}
runs = append(runs, run{rs.RunID, rs.CreatedAt})
}
sort.Slice(runs, func(i, j int) bool { return runs[i].created.After(runs[j].created) })
ids := make([]string, len(runs))
for i, r := range runs {
ids[i] = r.id
}
return ids, nil
}
// RunStep executes fn for (phase, name) unless it already completed
// successfully in a prior attempt at this run, in which case fn is skipped
// and the previously recorded StepOutcome is returned instead - this is
// what makes an interrupted run resumable without redoing (or worse,
// double-applying) work that already happened. State is persisted both
// before fn runs (so a crash during fn is visible as "running", not silently
// forgotten) and after (recording success or failure).
func (s *Store) RunStep(rs *RunState, phase Phase, name string, fn func() (StepOutcome, error)) (StepOutcome, error) {
if rs.Done(phase, name) {
return rs.Outcome(phase, name), nil
}
rs.Begin(phase, name)
if err := s.Save(rs); err != nil {
return StepOutcome{}, fmt.Errorf("checkpoint: persist step start for %s/%s: %w", phase, name, err)
}
outcome, stepErr := fn()
if stepErr != nil {
rs.Fail(phase, name, stepErr)
} else {
rs.Complete(phase, name, outcome)
}
if err := s.Save(rs); err != nil {
if stepErr != nil {
return outcome, fmt.Errorf("%w (additionally failed to persist checkpoint: %v)", stepErr, err)
}
return outcome, fmt.Errorf("step %s/%s succeeded but failed to persist checkpoint: %w", phase, name, err)
}
return outcome, stepErr
}
func newRunID() (string, error) {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102-150405"), hex.EncodeToString(b)), nil
}
+168
View File
@@ -0,0 +1,168 @@
package checkpoint
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestCreateSaveLoadRoundtrip(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
if rs.RunID == "" {
t.Fatal("Create: expected a non-empty run id")
}
rs.Topology = Topology{DeploymentKind: "systemd", StoreBackend: "rocksdb"}
rs.RecordArtifact("fs-backup", Artifact{Path: "/var/lib/stalwart.v0155-backup", SHA256: "deadbeef", SizeBytes: 1024})
if err := store.Save(rs); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.SourceVersion != "0.15.5" || loaded.TargetVersion != "0.16.14" {
t.Errorf("Load: versions = %s -> %s, want 0.15.5 -> 0.16.14", loaded.SourceVersion, loaded.TargetVersion)
}
if loaded.Topology.DeploymentKind != "systemd" {
t.Errorf("Load: DeploymentKind = %q, want systemd", loaded.Topology.DeploymentKind)
}
if got := loaded.Artifacts["fs-backup"].SHA256; got != "deadbeef" {
t.Errorf("Load: artifact sha256 = %q, want deadbeef", got)
}
}
func TestSaveIsAtomicNoLeftoverTempFiles(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
for i := 0; i < 5; i++ {
rs.Begin(PhasePreflight, "version")
rs.Complete(PhasePreflight, "version", StepOutcome{Verdict: "ok", Detail: "ok"})
if err := store.Save(rs); err != nil {
t.Fatalf("Save #%d: %v", i, err)
}
}
entries, err := os.ReadDir(filepath.Join(dir, rs.RunID))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if e.Name() != "state.json" {
t.Errorf("unexpected leftover file in run dir: %s", e.Name())
}
}
}
func TestRunStepSkipsAlreadyDoneStep(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
fn := func() (StepOutcome, error) {
calls++
return StepOutcome{Verdict: "ok", Detail: "did the thing"}, nil
}
outcome1, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #1: %v", err)
}
if outcome1.Detail != "did the thing" {
t.Errorf("RunStep #1 detail = %q, want %q", outcome1.Detail, "did the thing")
}
if calls != 1 {
t.Fatalf("calls after first RunStep = %d, want 1", calls)
}
// Simulate a resumed run: same rs, same step name. fn must not run again,
// and the previously recorded outcome must come back unchanged.
outcome2, err := store.RunStep(rs, PhasePreflight, "version", fn)
if err != nil {
t.Fatalf("RunStep #2 (resume): %v", err)
}
if calls != 1 {
t.Errorf("calls after resumed RunStep = %d, want 1 (fn should be skipped)", calls)
}
if outcome2 != outcome1 {
t.Errorf("RunStep #2 outcome = %+v, want %+v (unchanged from before)", outcome2, outcome1)
}
}
func TestRunStepRetriesAfterFailure(t *testing.T) {
store := NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create: %v", err)
}
calls := 0
failThenSucceed := func() (StepOutcome, error) {
calls++
if calls == 1 {
return StepOutcome{}, errors.New("transient failure")
}
return StepOutcome{Verdict: "ok", Detail: "succeeded on retry"}, nil
}
if _, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed); err == nil {
t.Fatal("RunStep #1: expected error, got nil")
}
if rs.Status(PhaseBackup, "fs-snapshot") != StepFailed {
t.Errorf("status after failed attempt = %s, want failed", rs.Status(PhaseBackup, "fs-snapshot"))
}
outcome, err := store.RunStep(rs, PhaseBackup, "fs-snapshot", failThenSucceed)
if err != nil {
t.Fatalf("RunStep #2 (retry): %v", err)
}
if calls != 2 {
t.Errorf("calls = %d, want 2 (failed step must retry, not skip)", calls)
}
if outcome.Detail != "succeeded on retry" {
t.Errorf("detail = %q, want %q", outcome.Detail, "succeeded on retry")
}
if !rs.Done(PhaseBackup, "fs-snapshot") {
t.Error("step should be Done after a successful retry")
}
}
func TestListOrdersNewestFirst(t *testing.T) {
store := NewStore(t.TempDir())
first, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatalf("Create first: %v", err)
}
first.CreatedAt = first.CreatedAt.Add(-time.Hour)
if err := store.Save(first); err != nil {
t.Fatalf("Save first: %v", err)
}
second, err := store.Create("0.16.14", "0.16.15")
if err != nil {
t.Fatalf("Create second: %v", err)
}
ids, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(ids) != 2 || ids[0] != second.RunID || ids[1] != first.RunID {
t.Errorf("List = %v, want [%s %s]", ids, second.RunID, first.RunID)
}
}
+121
View File
@@ -0,0 +1,121 @@
package checkpoint
import "time"
// Phase identifies one of the top-level migration phases from
// ARCHITECTURE.md §4. Step records are scoped to a phase so the same step
// name can be reused across phases without colliding.
type Phase string
const (
PhasePreflight Phase = "preflight"
PhaseBackup Phase = "backup"
PhaseStage Phase = "stage"
PhaseRecovery Phase = "recovery"
PhaseCutover Phase = "cutover"
PhaseValidate Phase = "validate"
PhaseRollback Phase = "rollback"
)
// StepStatus is the lifecycle state of one checkpointed step.
type StepStatus string
const (
StepPending StepStatus = "pending"
StepRunning StepStatus = "running"
StepDone StepStatus = "done"
StepFailed StepStatus = "failed"
)
// StepOutcome is what a step reports back on success: a verdict
// classification the calling phase defines the meaning of (e.g. preflight's
// "ok"/"warn"/"fail"), a human-readable summary, and an optional
// machine-readable value later steps - or a resumed run reconstructing this
// step's result without re-executing it - need. Keeping these three
// separate (rather than one free-text field) is what lets `stalwart-migrate
// status` print a clean human summary while still round-tripping the data a
// resumed run depends on.
type StepOutcome struct {
Verdict string `json:"verdict,omitempty"`
Detail string `json:"detail,omitempty"`
Extra string `json:"extra,omitempty"`
}
// StepRecord captures one step's lifecycle status plus its StepOutcome.
type StepRecord struct {
Phase Phase `json:"phase"`
Name string `json:"name"`
Status StepStatus `json:"status"`
StepOutcome // embedded (untagged) so its fields flatten into this JSON object
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// Artifact is a content-addressed record of a file a run produced (a
// backup, a settings dump, a downloaded release binary) so later phases and
// a human operator can confirm it hasn't changed underfoot.
type Artifact struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
}
// MailboxCount is one mailbox's message count as observed at preflight
// time, for later comparison against the post-migration count.
type MailboxCount struct {
Mailbox string `json:"mailbox"`
Messages int `json:"messages"`
}
// PreflightSnapshot holds the facts captured before anything is touched,
// which the validate phase later compares against the migrated instance.
// See ARCHITECTURE.md §4.1 and §4.7.
type PreflightSnapshot struct {
TakenAt time.Time `json:"taken_at"`
AccountCount int `json:"account_count"`
Domains []string `json:"domains,omitempty"`
MailboxCounts map[string][]MailboxCount `json:"mailbox_counts,omitempty"` // account -> mailboxes
DKIMFingerprints map[string]string `json:"dkim_fingerprints,omitempty"`
TLSFingerprints []string `json:"tls_fingerprints,omitempty"`
ListenerPorts []int `json:"listener_ports,omitempty"`
}
// Topology records how this Stalwart instance is deployed, as detected
// during preflight, so later phases (cutover, rollback) know whether
// they're managing a systemd unit or a container and what backend they're
// dealing with.
type Topology struct {
DeploymentKind string `json:"deployment_kind,omitempty"` // "systemd", "docker", "unknown"
ClusterNodes []string `json:"cluster_nodes,omitempty"`
StoreBackend string `json:"store_backend,omitempty"`
BlobStore string `json:"blob_store,omitempty"`
FTSBackend string `json:"fts_backend,omitempty"`
}
// RunState is the full persisted state of one migration run: everything
// needed to resume it after a crash, decide whether to roll back, or
// report on it later. See ARCHITECTURE.md §5.
type RunState struct {
RunID string `json:"run_id"`
SourceVersion string `json:"source_version"`
TargetVersion string `json:"target_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Topology Topology `json:"topology,omitempty"`
Steps []StepRecord `json:"steps"`
Artifacts map[string]Artifact `json:"artifacts,omitempty"`
PreflightSnapshot *PreflightSnapshot `json:"preflight_snapshot,omitempty"`
RollbackWindowClosed bool `json:"rollback_window_closed"`
}
// RecordArtifact stores a content-addressed record of a file this run
// produced, keyed by a short logical name (e.g. "fs-backup", "settings-dump",
// "target-binary") rather than its path, since the path alone doesn't prove
// the content is what this run actually wrote.
func (rs *RunState) RecordArtifact(name string, a Artifact) {
if rs.Artifacts == nil {
rs.Artifacts = map[string]Artifact{}
}
rs.Artifacts[name] = a
}