Check the migration actually kept everything, and let report say so

internal/validate was written and tested and then never called: `run` ended
at cutover, so the tool performed a migration and never confirmed it had
carried the data across, and `report` was an error message pointing at the
package that would have answered.

`run` now compares the migrated instance against the snapshot preflight took
and fails if an account or a domain that existed before is missing from it.
The comparison runs against the service cutover has just started, which is
the instance people will actually use - its real config, its real ports,
under its real service manager - and costs no extra downtime; booting a
second copy inside the maintenance window would. BootCheck stays as the
equivalent for an instance the tool boots itself.

The service is left running on a failure. By that point the store has been
migrated in place, so stopping it undoes nothing, and only the operator can
weigh the finding against their recovery point.

A check that could not run is reported as skipped, never as a pass. Preflight
only captures the "before" when it has an admin URL, and a run without one
has to say it compared nothing rather than imply everything survived - which
is the exact failure ARCHITECTURE.md §4.7 warns about. `report <run-id>`
re-reads the recorded verdict rather than re-checking: run again next week
and you would be asking how the instance looks now, not how it looked when
it was migrated.

§4.7 said validation ran after cutover while the only implementation booted
its own copy, and listed a suite far larger than what exists. It now says
which of the two happens, and which checks are real.
This commit is contained in:
2026-08-24 12:27:36 -07:00
parent 558af1005f
commit 28128633ef
9 changed files with 515 additions and 7 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ func main() {
case "status":
err = runStatus(os.Args[2:])
case "report":
err = fmt.Errorf("not implemented yet: see internal/validate")
err = runReport(os.Args[2:])
default:
usage()
os.Exit(1)
@@ -50,5 +50,5 @@ commands:
rehearse convert this instance's settings and report what will NOT carry over (read-only)
run perform the migration (needs --yes and --recovery-point-confirmed)
status show the state of an in-progress or completed run
report print the validation report for a run (not implemented yet)`)
report print the validation report for a run`)
}
+93
View File
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"flag"
"fmt"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
)
// runReport prints what validation found for a run, from the checkpoint the
// run already wrote. It re-reads rather than re-checks: the comparison is
// against a pre-migration snapshot, so running it again later would answer a
// different question — how the instance looks now, not how it looked when it
// was migrated.
func runReport(args []string) error {
fs := flag.NewFlagSet("report", flag.ExitOnError)
stateDir := fs.String("state-dir", checkpoint.DefaultBaseDir, "directory runs are checkpointed in")
runID, rest := splitRunID(fs, args)
if err := fs.Parse(rest); err != nil {
return err
}
if fs.NArg() != 0 || runID == "" {
return fmt.Errorf("usage: stalwart-migrate report <run-id> [flags]")
}
store := checkpoint.NewStore(*stateDir)
rs, err := store.Load(runID)
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
report := reportFromSteps(rs)
fmt.Printf("run: %s\n", rs.RunID)
fmt.Printf("source: %s\n", rs.SourceVersion)
fmt.Printf("target: %s\n", rs.TargetVersion)
if len(report.Results) == 0 {
fmt.Println("\nno validation has been recorded for this run.")
if rs.PreflightSnapshot == nil {
fmt.Println("preflight captured no pre-migration snapshot, so there was nothing to compare against;")
fmt.Println("pass --admin-url to preflight next time and the comparison becomes possible.")
} else {
fmt.Println("the run did not reach the validate phase - see `stalwart-migrate status " + runID + "`.")
}
return nil
}
fmt.Println("\nvalidation:")
fmt.Print(report.String())
if report.Blocking() {
// Non-zero so this is usable in a script that gates on it, and so a
// failed migration cannot look successful to anything watching.
return fmt.Errorf("validation recorded a failure for run %s", runID)
}
return nil
}
// reportFromSteps rebuilds the validation report from the checkpointed
// steps, so the report survives the process that produced it.
func reportFromSteps(rs *checkpoint.RunState) validate.Report {
var report validate.Report
for _, step := range rs.Steps {
if step.Phase != checkpoint.PhaseValidate {
continue
}
status := validate.StatusOK
switch {
case step.Error != "":
status = validate.StatusFail
case step.Verdict == string(validate.StatusFail):
status = validate.StatusFail
case step.Verdict == string(validate.StatusSkip):
status = validate.StatusSkip
case step.Status != checkpoint.StepDone:
// Recorded but never completed: the run stopped partway.
status = validate.StatusFail
}
detail := step.Detail
if step.Error != "" {
if detail != "" {
detail += " "
}
detail += "(error: " + step.Error + ")"
}
report.Results = append(report.Results, validate.CheckResult{Name: step.Name, Status: status, Detail: detail})
}
return report
}
+102
View File
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
)
// `report` re-reads what the run recorded rather than re-checking, so the
// mapping from checkpointed step back to verdict is the whole command. The
// case that matters is a failure staying a failure: a migration that lost an
// account must not read as clean the next morning.
func TestReportFromSteps(t *testing.T) {
for _, tc := range []struct {
name string
step checkpoint.StepRecord
want validate.Status
block bool
}{
{
name: "a completed comparison is a pass",
step: checkpoint.StepRecord{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepDone, StepOutcome: checkpoint.StepOutcome{Detail: "12 accounts checked"}},
want: validate.StatusOK,
},
{
name: "a failing verdict survives the round trip",
step: checkpoint.StepRecord{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepDone, StepOutcome: checkpoint.StepOutcome{Verdict: string(validate.StatusFail), Detail: "missing accounts: [email protected]"}},
want: validate.StatusFail,
block: true,
},
{
name: "a skipped check stays skipped, not a pass",
step: checkpoint.StepRecord{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepDone, StepOutcome: checkpoint.StepOutcome{Verdict: string(validate.StatusSkip), Detail: "no snapshot"}},
want: validate.StatusSkip,
},
{
name: "a step that errored is a failure",
step: checkpoint.StepRecord{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepFailed, Error: "connection refused"},
want: validate.StatusFail,
block: true,
},
{
name: "a step that never finished is a failure, not a pass",
step: checkpoint.StepRecord{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepRunning},
want: validate.StatusFail,
block: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := reportFromSteps(&checkpoint.RunState{Steps: []checkpoint.StepRecord{tc.step}})
if len(got.Results) != 1 {
t.Fatalf("got %d results, want 1", len(got.Results))
}
if got.Results[0].Status != tc.want {
t.Fatalf("status = %q, want %q", got.Results[0].Status, tc.want)
}
if got.Blocking() != tc.block {
t.Fatalf("Blocking() = %v, want %v", got.Blocking(), tc.block)
}
})
}
}
func TestReportFromStepsIgnoresOtherPhases(t *testing.T) {
rs := &checkpoint.RunState{Steps: []checkpoint.StepRecord{
{Phase: checkpoint.PhaseCutover, Name: "start-service", Status: checkpoint.StepDone},
{Phase: checkpoint.PhasePreflight, Name: "disk-space", Status: checkpoint.StepFailed, Error: "nope"},
{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepDone, StepOutcome: checkpoint.StepOutcome{Detail: "ok"}},
}}
got := reportFromSteps(rs)
if len(got.Results) != 1 || got.Results[0].Name != "content-integrity" {
t.Fatalf("expected only the validate phase, got %+v", got.Results)
}
// A failed preflight step must not leak into the validation verdict.
if got.Blocking() {
t.Fatal("a failure in another phase must not make validation look failed")
}
}
func TestReportFromStepsOnARunThatNeverValidated(t *testing.T) {
rs := &checkpoint.RunState{Steps: []checkpoint.StepRecord{
{Phase: checkpoint.PhaseCutover, Name: "start-service", Status: checkpoint.StepDone},
}}
if got := reportFromSteps(rs); len(got.Results) != 0 {
t.Fatalf("expected no results, got %+v", got.Results)
}
}
func TestReportFromStepsKeepsTheError(t *testing.T) {
rs := &checkpoint.RunState{Steps: []checkpoint.StepRecord{
{Phase: checkpoint.PhaseValidate, Name: "content-integrity", Status: checkpoint.StepFailed, StepOutcome: checkpoint.StepOutcome{Detail: "reached the instance"}, Error: "401 unauthorized"},
}}
got := reportFromSteps(rs)
if d := got.Results[0].Detail; d != "reached the instance (error: 401 unauthorized)" {
t.Fatalf("detail = %q, want it to carry both the detail and the error", d)
}
}
+19
View File
@@ -21,6 +21,7 @@ import (
"github.com/LINUXexpert-org/stalwart-migrator/internal/recovery"
"github.com/LINUXexpert-org/stalwart-migrator/internal/service"
"github.com/LINUXexpert-org/stalwart-migrator/internal/stage"
"github.com/LINUXexpert-org/stalwart-migrator/internal/validate"
)
// runRun implements `stalwart-migrate run`: the real migration.
@@ -334,6 +335,24 @@ func runRun(args []string) (err error) {
return fmt.Errorf("cutover failed: %w", err)
}
fmt.Println("\n--- validate ---")
valReport, valErr := validate.RunLive(ctx, store, rs, validate.LiveOptions{
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
HTTPClient: httpClient, Before: rs.PreflightSnapshot,
})
fmt.Print(valReport.String())
if valErr != nil {
return fmt.Errorf("the migrated service is up, but validation could not complete - check it by hand before "+
"treating this migration as done: %w", valErr)
}
if valReport.Blocking() {
// The service is live and serving mail; that is deliberately not
// undone here. The operator has the finding and their recovery
// point, and only they can weigh one against the other.
return fmt.Errorf("the migrated service is up, but accounts or domains from before the migration are missing " +
"from it - see the FAIL line above. Your recovery point is the way back; this tool will not undo a migration")
}
fmt.Printf("\nMIGRATION COMPLETE for run %s. Mail was down for %s.\n",
rs.RunID, time.Since(windowStart).Round(time.Second))
fmt.Printf("Now: confirm you can log in as %s, send and receive a test message, and work through\n", *adminUser)