Files
jcoffey-dev 12ec0c3fd4 Rename the module to the Coffey-Labs organisation
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.
2026-08-30 15:24:18 -07:00

134 lines
5.0 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package recovery
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
)
// Options configures one full recovery-mode migration cycle: starting the
// target binary in recovery mode, waiting for it to come up, applying the
// settings snapshot(s), and stopping it again. See ARCHITECTURE.md §4.4.
type Options struct {
BinaryPath string
ConfigPath string
ListenURL string // recovery mode's own HTTP listener, e.g. "http://127.0.0.1:8080"
AdminUser string
ApplyFiles []string
CLIBinaryPath string
ExtraEnv []string // lets a dry-run point ports/paths at a sandbox without touching production config
StartupTimeout time.Duration
StopGrace time.Duration
HTTPClient *http.Client
// Launcher starts the target version. Nil means BinaryPath as a child
// process, which is what every caller wants today; a container
// deployment supplies its own (issue #3).
Launcher Launcher
}
// GenerateRecoveryPassword returns a fresh random one-time password for
// STALWART_RECOVERY_ADMIN - never the operator's real admin password, never
// logged, never reused across runs or persisted to the checkpoint.
func GenerateRecoveryPassword() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("recovery: generate password: %w", err)
}
return hex.EncodeToString(b), nil
}
// Run executes one recovery-mode cycle as a single checkpointed step. It is
// deliberately not decomposed into per-sub-step checkpoints the way
// preflight and backup are: if this tool's own process crashes mid-cycle,
// the child Stalwart process it started may or may not still be running
// independently, and blindly "resuming" by reattaching to a guessed PID or
// killing an unrelated process on the recovery port would be more dangerous
// than just retrying cleanly. A retry that hits "address already in use"
// surfaces the real problem (an orphaned process from the failed attempt)
// for a human to clear, rather than this tool guessing at cleanup.
//
// Whatever happens after Start succeeds, Stop is always attempted on the
// way out (via a deferred call), so a failure partway through this cycle
// doesn't leak the child process within a single invocation.
func Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts Options) (Report, error) {
var report Report
outcome, err := store.RunStep(rs, checkpoint.PhaseRecovery, "recovery-cycle", func() (out checkpoint.StepOutcome, err error) {
password, err := GenerateRecoveryPassword()
if err != nil {
return checkpoint.StepOutcome{}, err
}
launcher := opts.Launcher
if launcher == nil {
launcher = BinaryLauncher{BinaryPath: opts.BinaryPath}
}
proc, startErr := launcher.Launch(ctx, LaunchOptions{
ConfigPath: opts.ConfigPath,
RecoveryMode: true, AdminUser: opts.AdminUser, AdminPassword: password,
ExtraEnv: opts.ExtraEnv,
})
if startErr != nil {
return checkpoint.StepOutcome{}, startErr
}
stopGrace := opts.StopGrace
if stopGrace <= 0 {
stopGrace = 10 * time.Second
}
defer func() {
if stopErr := proc.Stop(stopGrace); stopErr != nil && err == nil {
err = stopErr
}
}()
startupTimeout := opts.StartupTimeout
if startupTimeout <= 0 {
startupTimeout = 60 * time.Second
}
if healthErr := WaitForHealthy(ctx, opts.HTTPClient, opts.ListenURL, startupTimeout); healthErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("recovery mode did not come up: %w%s", healthErr, outputSuffix(proc))
}
if applyErr := ApplyAll(ctx, ApplyOptions{
CLIBinaryPath: opts.CLIBinaryPath, URL: opts.ListenURL, User: opts.AdminUser, Password: password,
}, opts.ApplyFiles); applyErr != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("settings apply failed: %w%s", applyErr, outputSuffix(proc))
}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("recovery mode came up at %s, applied %d settings file(s), stopped cleanly", opts.ListenURL, len(opts.ApplyFiles)),
}, nil
})
if err != nil {
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusFail, Detail: err.Error()})
return report, err
}
report.Results = append(report.Results, CheckResult{Name: "recovery-cycle", Status: StatusOK, Detail: outcome.Detail})
return report, nil
}
// outputSuffix renders a supervised process's captured output for
// appending to an error, or nothing if it produced none. The server's own
// words are usually the whole diagnosis - a bind conflict, a rejected
// config value - and without them the caller is left guessing at a
// timeout.
func outputSuffix(proc Supervised) string {
out := strings.TrimSpace(proc.Output())
if out == "" {
return " (the process produced no output)"
}
return fmt.Sprintf("\n--- output from the supervised Stalwart process ---\n%s\n--- end of output ---", out)
}