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.
94 lines
3.0 KiB
Go
94 lines
3.0 KiB
Go
// 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
|
|
}
|