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.
97 lines
3.7 KiB
Go
97 lines
3.7 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package validate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
|
|
)
|
|
|
|
// LiveOptions describes the migrated instance cutover has just started.
|
|
type LiveOptions struct {
|
|
AdminURL string
|
|
AdminUser string
|
|
AdminPassword string
|
|
HTTPClient *http.Client
|
|
|
|
// Before is what preflight captured before anything was touched
|
|
// (checkpoint.RunState.PreflightSnapshot). Nil when preflight had no
|
|
// admin URL to capture it from, in which case there is nothing to
|
|
// compare against and the check reports that rather than passing.
|
|
Before *checkpoint.PreflightSnapshot
|
|
}
|
|
|
|
// CheckLive compares a running instance against the pre-migration snapshot.
|
|
//
|
|
// The same comparison BootCheck performs against an instance it booted
|
|
// itself, aimed instead at the service cutover has already started. That is
|
|
// the instance people will actually use — its real config, its real ports,
|
|
// under its real service manager — and checking it costs no extra downtime,
|
|
// where booting a second copy inside the maintenance window would.
|
|
func CheckLive(ctx context.Context, client *stalwartapi.Client, before *checkpoint.PreflightSnapshot) (*ContentIntegrityResult, error) {
|
|
return compareContentIntegrity(ctx, client, before)
|
|
}
|
|
|
|
// RunLive executes the post-cutover content comparison as a checkpointed
|
|
// step, mirroring how every other phase records itself.
|
|
//
|
|
// A missing snapshot or admin URL is reported as skipped, never as a pass:
|
|
// "every account survived" and "we were unable to look" are different
|
|
// answers, and ARCHITECTURE.md §4.7 is explicit that this suite must not
|
|
// imply a guarantee it did not measure.
|
|
func RunLive(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts LiveOptions) (Report, error) {
|
|
var report Report
|
|
|
|
switch {
|
|
case opts.AdminURL == "":
|
|
report.Results = append(report.Results, CheckResult{
|
|
Name: "content-integrity", Status: StatusSkip,
|
|
Detail: "no admin URL configured - nothing could be compared against the migrated instance",
|
|
})
|
|
return report, nil
|
|
case opts.Before == nil:
|
|
report.Results = append(report.Results, CheckResult{
|
|
Name: "content-integrity", Status: StatusSkip,
|
|
Detail: "preflight captured no pre-migration snapshot - there is nothing to compare the migrated instance against",
|
|
})
|
|
return report, nil
|
|
}
|
|
|
|
client := &stalwartapi.Client{
|
|
BaseURL: opts.AdminURL, Username: opts.AdminUser, Password: opts.AdminPassword, HTTPClient: opts.HTTPClient,
|
|
}
|
|
|
|
outcome, err := store.RunStep(rs, checkpoint.PhaseValidate, "content-integrity", func() (checkpoint.StepOutcome, error) {
|
|
r, err := CheckLive(ctx, client, opts.Before)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
if !r.OK() {
|
|
// Recorded as a completed step with a failing verdict rather
|
|
// than an error: the comparison ran, and its answer is the
|
|
// finding. An error here would read as "we could not look".
|
|
return checkpoint.StepOutcome{Verdict: string(StatusFail), Detail: r.String()}, nil
|
|
}
|
|
return checkpoint.StepOutcome{Detail: r.String()}, nil
|
|
})
|
|
if err != nil {
|
|
report.Results = append(report.Results, CheckResult{
|
|
Name: "content-integrity", Status: StatusFail,
|
|
Detail: fmt.Sprintf("could not compare the migrated instance against the pre-migration snapshot: %v", err),
|
|
})
|
|
return report, err
|
|
}
|
|
|
|
status := StatusOK
|
|
if outcome.Verdict == string(StatusFail) {
|
|
status = StatusFail
|
|
}
|
|
report.Results = append(report.Results, CheckResult{Name: "content-integrity", Status: status, Detail: outcome.Detail})
|
|
return report, nil
|
|
}
|