Both phases that bring the target version up against a not-yet-migrated store -- the recovery cycle, and the ordinary boot validate does after it -- constructed a child process from a path on this host directly. That is the one thing about them packaging changes: a container runs an image against the data volume instead. Everything either of them is started *for* is identical afterwards. So starting it is now a Launcher, returning a Supervised the callers stop and read output from. BinaryLauncher is today's behaviour and the default when Options.Launcher is nil, so every existing caller is unchanged -- no test needed editing, which is the evidence for that rather than a claim about it. Deliberately narrower than the interface sketched in issue #3. Stage and cutover also differ by packaging, but designing their interfaces now would be designing against a guess: there is no second implementation yet to shape them, and the shape a container needs is what PR 3 and PR 4 find out. This seam is different because it already had two callers doing the same thing for the same reason, so extracting it describes the code rather than predicting it. outputSuffix now takes Supervised. It only ever needed Output(), and the diagnosis it exists to preserve -- the server's own words about a bind conflict or a rejected config value, which a bare timeout loses -- matters whatever started the process.
137 lines
5.3 KiB
Go
137 lines
5.3 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package validate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/recovery"
|
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
|
|
)
|
|
|
|
// BootCheckOptions configures a normal (non-recovery-mode) boot of the
|
|
// migrated instance, to confirm it comes up cleanly outside recovery mode -
|
|
// not just that recovery mode itself could apply settings to it - and,
|
|
// optionally, a content-integrity comparison performed against that same
|
|
// boot before it's stopped again.
|
|
type BootCheckOptions struct {
|
|
BinaryPath string
|
|
ConfigPath string
|
|
ListenURL string
|
|
ExtraEnv []string
|
|
Timeout time.Duration
|
|
StopGrace time.Duration
|
|
HTTPClient *http.Client
|
|
|
|
// Launcher starts the instance this boots. Nil means BinaryPath as a
|
|
// child process - see recovery.Launcher.
|
|
Launcher recovery.Launcher
|
|
|
|
// ContentIntegrityBefore, if non-nil, is the pre-migration snapshot
|
|
// preflight captured (checkpoint.RunState.PreflightSnapshot). When set,
|
|
// BootCheck captures a fresh snapshot from the instance it just booted
|
|
// - authenticating with AdminUser/AdminPassword, which migrate over
|
|
// unchanged with the account (they don't need to differ from the
|
|
// pre-migration admin credentials) - and compares the two: this is the
|
|
// actual no-data-loss guarantee from ARCHITECTURE.md §4.7, not just
|
|
// "the migration mechanics ran". Left nil, only the boot-reachability
|
|
// check runs, e.g. when preflight never captured a snapshot because
|
|
// --admin-url wasn't set.
|
|
ContentIntegrityBefore *checkpoint.PreflightSnapshot
|
|
AdminUser string
|
|
AdminPassword string
|
|
}
|
|
|
|
// BootCheck starts the target binary the way cutover eventually will (an
|
|
// ordinary boot, no STALWART_RECOVERY_MODE), waits for its HTTP listener to
|
|
// answer, optionally compares its content against ContentIntegrityBefore
|
|
// while it's up, then stops it. It reuses recovery.Process and
|
|
// recovery.WaitForHealthy rather than re-implementing process supervision,
|
|
// since "start the binary and confirm it's reachable" is exactly what those
|
|
// already do.
|
|
//
|
|
// Like recovery.Run, this is deliberately one atomic operation rather than
|
|
// separately checkpointed sub-steps: if this tool's own process crashes
|
|
// between the boot succeeding and the content check running, there's no
|
|
// safe way to reattach to whatever's left of the child process on resume,
|
|
// so a retry just redoes the whole cycle - see recovery.Run's doc comment
|
|
// for the full reasoning, which applies identically here.
|
|
func BootCheck(ctx context.Context, o BootCheckOptions) (detail string, result *ContentIntegrityResult, err error) {
|
|
launcher := o.Launcher
|
|
if launcher == nil {
|
|
launcher = recovery.BinaryLauncher{BinaryPath: o.BinaryPath}
|
|
}
|
|
proc, startErr := launcher.Launch(ctx, recovery.LaunchOptions{
|
|
ConfigPath: o.ConfigPath, RecoveryMode: false, ExtraEnv: o.ExtraEnv,
|
|
})
|
|
if startErr != nil {
|
|
return "", nil, fmt.Errorf("validate: start normal boot: %w", startErr)
|
|
}
|
|
|
|
stopGrace := o.StopGrace
|
|
if stopGrace <= 0 {
|
|
stopGrace = 10 * time.Second
|
|
}
|
|
defer func() {
|
|
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
|
|
err = stopErr
|
|
}
|
|
}()
|
|
|
|
timeout := o.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
if healthErr := recovery.WaitForHealthy(ctx, o.HTTPClient, o.ListenURL, timeout); healthErr != nil {
|
|
out := strings.TrimSpace(proc.Output())
|
|
if out == "" {
|
|
out = "(the process produced no output)"
|
|
}
|
|
return "", nil, fmt.Errorf("migrated instance did not come up under a normal (non-recovery-mode) boot: %w\n"+
|
|
"--- output from the supervised Stalwart process ---\n%s\n--- end of output ---", healthErr, out)
|
|
}
|
|
detail = fmt.Sprintf("migrated instance booted normally (not in recovery mode) and answered at %s", o.ListenURL)
|
|
|
|
if o.ContentIntegrityBefore == nil {
|
|
return detail, nil, nil
|
|
}
|
|
|
|
client := &stalwartapi.Client{BaseURL: o.ListenURL, Username: o.AdminUser, Password: o.AdminPassword, HTTPClient: o.HTTPClient}
|
|
result, ciErr := compareContentIntegrity(ctx, client, o.ContentIntegrityBefore)
|
|
if ciErr != nil {
|
|
return detail, nil, fmt.Errorf("content-integrity comparison failed: %w", ciErr)
|
|
}
|
|
if !result.OK() {
|
|
return detail, result, fmt.Errorf("content integrity check found problems: %s", result.String())
|
|
}
|
|
return detail, result, nil
|
|
}
|
|
|
|
// Run executes BootCheck as a single checkpointed step, mirroring
|
|
// preflight/backup/recovery's pattern.
|
|
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts BootCheckOptions) (Report, error) {
|
|
var report Report
|
|
outcome, err := store.RunStep(rs, checkpoint.PhaseValidate, "boot-check", func() (checkpoint.StepOutcome, error) {
|
|
detail, result, err := BootCheck(ctx, opts)
|
|
if err != nil {
|
|
return checkpoint.StepOutcome{}, err
|
|
}
|
|
if result != nil {
|
|
detail += " - " + result.String()
|
|
}
|
|
return checkpoint.StepOutcome{Detail: detail}, nil
|
|
})
|
|
if err != nil {
|
|
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusFail, Detail: err.Error()})
|
|
return report, err
|
|
}
|
|
report.Results = append(report.Results, CheckResult{Name: "boot-check", Status: StatusOK, Detail: outcome.Detail})
|
|
return report, nil
|
|
}
|