The repositories moved off LINUXexpert-org. Here that is not a documentation change: the old organisation was the module path, so it is declared in go.mod and repeated in every internal import. Leaving it would have been worse than a stale link. GitHub redirects the repository, but a go.mod whose module line disagrees with the path it was fetched from is an error rather than a redirect, so `go get` on the new address would have failed against the old declaration. go.mod, 34 files of imports, and the repository links in README and ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved, only our own path.
117 lines
4.8 KiB
Go
117 lines
4.8 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package validate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
|
|
"github.com/Coffey-Labs/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
|
|
}
|
|
switch {
|
|
case r.Inconclusive():
|
|
// Fewer accounts came back than existed, so "missing" cannot be
|
|
// told apart from "not permitted to see". Still a failure - an
|
|
// unverified migration is not a verified one - but it must not
|
|
// be reported as data loss, which is a different claim and one
|
|
// this evidence does not support.
|
|
return checkpoint.StepOutcome{Verdict: string(StatusFail), Detail: fmt.Sprintf(
|
|
"COULD NOT VERIFY (not the same as data loss): the migrated instance showed %d of %d account(s) to %s. "+
|
|
"Either those accounts are gone, or this account cannot see them - enumeration is permission-scoped, and a "+
|
|
"migration does not always carry an admin role across. Re-check with an account that holds admin on the "+
|
|
"migrated instance before concluding either. Findings: %s",
|
|
r.AccountsVisibleAfter, r.AccountsChecked, opts.AdminUser, r.String())}, nil
|
|
case !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
|
|
case !r.DomainsOK():
|
|
// The two versions disagree about what counts as a domain, so
|
|
// this is reported rather than treated as data loss.
|
|
return checkpoint.StepOutcome{Verdict: string(StatusWarn), 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
|
|
switch outcome.Verdict {
|
|
case string(StatusFail):
|
|
status = StatusFail
|
|
case string(StatusWarn):
|
|
status = StatusWarn
|
|
}
|
|
report.Results = append(report.Results, CheckResult{Name: "content-integrity", Status: status, Detail: outcome.Detail})
|
|
return report, nil
|
|
}
|