Initial commit: stalwart-migrator design and scaffolding
In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with checkpointed rollback and post-migration validation. Design stage; see ARCHITECTURE.md.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
|
||||
"github.com/johnellis/stalwart-migrator/internal/stalwartapi"
|
||||
)
|
||||
|
||||
// Options configures a Checker. Every field has a conservative default
|
||||
// applied by New except the ones that must name a real path on this host.
|
||||
type Options struct {
|
||||
BinaryPath string // installed stalwart binary, e.g. /usr/local/bin/stalwart
|
||||
ConfigPath string // its config file (TOML pre-0.16, JSON 0.16+)
|
||||
DataDir string // data directory to size/space-check
|
||||
ContainerName string // docker container name, if applicable
|
||||
AdminURL string // base URL for the JMAP reachability check; empty skips it
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
TargetVersion string // e.g. "0.16.14" or "latest"
|
||||
MinFreeMultiple float64
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Checker runs the preflight checks described in ARCHITECTURE.md §4.1.
|
||||
type Checker struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
func New(opts Options) *Checker {
|
||||
if opts.MinFreeMultiple <= 0 {
|
||||
opts.MinFreeMultiple = 2.0
|
||||
}
|
||||
return &Checker{opts: opts}
|
||||
}
|
||||
|
||||
// Run executes every preflight check, checkpointing each one so a killed
|
||||
// and re-invoked run skips checks that already completed - see
|
||||
// checkpoint.Store.RunStep. It never aborts early on a single Fail: the
|
||||
// point of preflight is to surface every blocking issue in one pass rather
|
||||
// than fail-stop-fix-retry one at a time. Callers decide what to do with a
|
||||
// Report whose Blocking() is true. It only returns a non-nil error for a
|
||||
// genuine execution fault (e.g. the checkpoint store itself can't be
|
||||
// written to) - a check finding a real problem is reported via
|
||||
// Status: StatusFail in the Report, not a Go error.
|
||||
func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState) (Report, error) {
|
||||
var report Report
|
||||
|
||||
// runCheck wraps fn as a checkpointed step and appends its result to
|
||||
// report, whether fn actually ran or was skipped because a prior
|
||||
// attempt already completed it - either way report ends up with the
|
||||
// same entries, and the returned checkpoint.StepOutcome.Extra carries
|
||||
// whatever machine-readable value a later check in this same Run needs.
|
||||
runCheck := func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error) {
|
||||
outcome, err := store.RunStep(rs, checkpoint.PhasePreflight, name, func() (checkpoint.StepOutcome, error) {
|
||||
res, extra := fn()
|
||||
return checkpoint.StepOutcome{Verdict: string(res.Status), Detail: res.Detail, Extra: extra}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return checkpoint.StepOutcome{}, err
|
||||
}
|
||||
report.Results = append(report.Results, CheckResult{Name: name, Status: Status(outcome.Verdict), Detail: outcome.Detail})
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
versionOutcome, err := runCheck("version", func() (CheckResult, string) {
|
||||
cur, err := DetectVersion(ctx, c.opts.BinaryPath)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
curV, _ := parseSemver(cur)
|
||||
if curV.Compare(minSupportedSource) < 0 {
|
||||
return CheckResult{
|
||||
Status: StatusFail,
|
||||
Detail: fmt.Sprintf("current version %s is older than the minimum supported %s - upgrade to 0.15.x first", cur, minSupportedSource),
|
||||
}, cur
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("current version %s", cur)}, cur
|
||||
})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
targetOutcome, err := runCheck("target-release", func() (CheckResult, string) {
|
||||
rel, err := ResolveRelease(ctx, c.opts.HTTPClient, c.opts.TargetVersion)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
tag := strings.TrimPrefix(rel.TagName, "v")
|
||||
detail := fmt.Sprintf("resolved target to %s (%d release assets)", rel.TagName, len(rel.Assets))
|
||||
if asset := ChecksumAsset(rel); asset != nil {
|
||||
detail += fmt.Sprintf(", checksum manifest available: %s", asset.Name)
|
||||
} else {
|
||||
detail += "; no published checksum manifest found - integrity relies on the one-time HTTPS download only"
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: detail}, tag
|
||||
})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
if _, err := runCheck("upgrade-direction", func() (CheckResult, string) {
|
||||
curV, errCur := parseSemver(versionOutcome.Extra)
|
||||
tgtV, errTgt := parseSemver(targetOutcome.Extra)
|
||||
if errCur != nil || errTgt != nil {
|
||||
return CheckResult{Status: StatusWarn, Detail: "could not compare current and target versions (one or both unresolved above)"}, ""
|
||||
}
|
||||
if curV.Compare(tgtV) >= 0 {
|
||||
return CheckResult{
|
||||
Status: StatusFail,
|
||||
Detail: fmt.Sprintf("current version %s is already at or beyond target %s - nothing to migrate", curV, tgtV),
|
||||
}, ""
|
||||
}
|
||||
if curV.Major == 0 && curV.Minor < 16 && (tgtV.Major > 0 || tgtV.Minor >= 16) {
|
||||
return CheckResult{
|
||||
Status: StatusOK,
|
||||
Detail: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration plan required (ARCHITECTURE.md §4.4)", curV, tgtV),
|
||||
}, ""
|
||||
}
|
||||
return CheckResult{
|
||||
Status: StatusOK,
|
||||
Detail: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast-path plan applies (ARCHITECTURE.md §4.6)", curV, tgtV),
|
||||
}, ""
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
deploymentOutcome, err := runCheck("deployment-kind", func() (CheckResult, string) {
|
||||
kind := DetectDeploymentKind(ctx, c.opts.ContainerName)
|
||||
status := StatusOK
|
||||
if kind == DeploymentUnknown {
|
||||
status = StatusWarn
|
||||
}
|
||||
return CheckResult{Status: status, Detail: fmt.Sprintf("detected deployment kind: %s", kind)}, string(kind)
|
||||
})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
|
||||
matches, err := DetectStoreBackends(c.opts.ConfigPath)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return CheckResult{Status: StatusWarn, Detail: "no known store backend type found in config - confirm manually before proceeding"}, ""
|
||||
}
|
||||
names := make([]string, len(matches))
|
||||
backends := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
names[i] = fmt.Sprintf("%s (%s)", m.Backend, m.Path)
|
||||
backends[i] = m.Backend
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: "found: " + strings.Join(names, ", ")}, strings.Join(backends, ",")
|
||||
})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
if _, err := runCheck("cluster-config", func() (CheckResult, string) {
|
||||
clustered, err := LooksClustered(c.opts.ConfigPath)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
if clustered {
|
||||
return CheckResult{
|
||||
Status: StatusWarn,
|
||||
Detail: "config mentions clustering - confirm every peer node is stopped before this run proceeds; the tool does not verify this for you",
|
||||
}, ""
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: "no cluster configuration detected"}, ""
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
if _, err := runCheck("disk-space", func() (CheckResult, string) {
|
||||
size, err := DirSize(c.opts.DataDir)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
free, err := FreeBytes(c.opts.DataDir)
|
||||
if err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
required := uint64(float64(size) * c.opts.MinFreeMultiple)
|
||||
detail := fmt.Sprintf("data dir %s is %s, %s free, need >= %s (%.1fx, for the fs-snapshot backup)",
|
||||
c.opts.DataDir, humanBytes(uint64(size)), humanBytes(free), humanBytes(required), c.opts.MinFreeMultiple)
|
||||
if free < required {
|
||||
return CheckResult{Status: StatusFail, Detail: detail}, ""
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: detail}, ""
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
if c.opts.AdminURL != "" {
|
||||
if _, err := runCheck("admin-reachable", func() (CheckResult, string) {
|
||||
client := &stalwartapi.Client{
|
||||
BaseURL: c.opts.AdminURL,
|
||||
Username: c.opts.AdminUser,
|
||||
Password: c.opts.AdminPassword,
|
||||
HTTPClient: c.opts.HTTPClient,
|
||||
}
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
return CheckResult{Status: StatusFail, Detail: err.Error()}, ""
|
||||
}
|
||||
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("JMAP session reachable at %s with the given credentials", c.opts.AdminURL)}, ""
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
if _, err := runCheck("account-snapshot", func() (CheckResult, string) {
|
||||
client := &stalwartapi.Client{
|
||||
BaseURL: c.opts.AdminURL,
|
||||
Username: c.opts.AdminUser,
|
||||
Password: c.opts.AdminPassword,
|
||||
HTTPClient: c.opts.HTTPClient,
|
||||
}
|
||||
snap, err := client.AccountSnapshot(ctx)
|
||||
if err != nil {
|
||||
return CheckResult{
|
||||
Status: StatusWarn,
|
||||
Detail: fmt.Sprintf("could not capture the account/domain snapshot: %v - the post-migration directory-integrity check won't have anything to compare against", err),
|
||||
}, ""
|
||||
}
|
||||
mailboxCounts := make(map[string][]checkpoint.MailboxCount, len(snap.MailboxCounts))
|
||||
for account, counts := range snap.MailboxCounts {
|
||||
converted := make([]checkpoint.MailboxCount, len(counts))
|
||||
for i, mc := range counts {
|
||||
converted[i] = checkpoint.MailboxCount{Mailbox: mc.Mailbox, Messages: mc.Messages}
|
||||
}
|
||||
mailboxCounts[account] = converted
|
||||
}
|
||||
rs.PreflightSnapshot = &checkpoint.PreflightSnapshot{
|
||||
TakenAt: time.Now().UTC(),
|
||||
AccountCount: snap.AccountCount,
|
||||
Domains: snap.Domains,
|
||||
MailboxCounts: mailboxCounts,
|
||||
}
|
||||
detail := fmt.Sprintf("captured snapshot: %d account(s) across %d domain(s), mailbox counts for %d account(s)",
|
||||
snap.AccountCount, len(snap.Domains), len(mailboxCounts))
|
||||
status := StatusOK
|
||||
if len(snap.MailboxErrors) > 0 {
|
||||
status = StatusWarn
|
||||
accounts := make([]string, 0, len(snap.MailboxErrors))
|
||||
for account := range snap.MailboxErrors {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
sort.Strings(accounts)
|
||||
for i, account := range accounts {
|
||||
if i >= 3 {
|
||||
detail += fmt.Sprintf(" (and %d more)", len(accounts)-3)
|
||||
break
|
||||
}
|
||||
detail += fmt.Sprintf("; mailbox count failed for %s: %s", account, snap.MailboxErrors[account])
|
||||
}
|
||||
}
|
||||
return CheckResult{Status: status, Detail: detail}, ""
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
} else {
|
||||
report.Results = append(report.Results, CheckResult{
|
||||
Name: "admin-reachable",
|
||||
Status: StatusWarn,
|
||||
Detail: "no --admin-url configured - skipped; the account/mailbox snapshot validate needs later can't be captured without it",
|
||||
})
|
||||
}
|
||||
|
||||
rs.Topology = checkpoint.Topology{
|
||||
DeploymentKind: deploymentOutcome.Extra,
|
||||
StoreBackend: storeOutcome.Extra,
|
||||
}
|
||||
if versionOutcome.Extra != "" {
|
||||
rs.SourceVersion = versionOutcome.Extra
|
||||
}
|
||||
if targetOutcome.Extra != "" {
|
||||
rs.TargetVersion = targetOutcome.Extra
|
||||
}
|
||||
if err := store.Save(rs); err != nil {
|
||||
return report, fmt.Errorf("preflight: persist topology: %w", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
|
||||
)
|
||||
|
||||
// writeFakeBinary creates a shell script that behaves like `stalwart
|
||||
// --version` and records each invocation to counterPath, so tests can
|
||||
// assert a checkpointed step was (or wasn't) re-executed on resume.
|
||||
func writeFakeBinary(t *testing.T, version, counterPath string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
scriptPath := filepath.Join(dir, "fake-stalwart.sh")
|
||||
script := fmt.Sprintf("#!/bin/sh\necho invoked >> %q\necho 'stalwart %s'\n", counterPath, version)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func countLines(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(strings.Split(strings.TrimSpace(string(data)), "\n"))
|
||||
}
|
||||
|
||||
func TestCheckerRunEndToEndAndResume(t *testing.T) {
|
||||
// -- fixtures --------------------------------------------------------
|
||||
counterPath := filepath.Join(t.TempDir(), "invocations")
|
||||
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.toml")
|
||||
tomlCfg := "[store.\"rocksdb\"]\ntype = \"rocksdb\"\npath = \"/var/lib/stalwart/data\"\n"
|
||||
if err := os.WriteFile(configPath, []byte(tomlCfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{
|
||||
TagName: "v0.16.14",
|
||||
Assets: []ReleaseAsset{{Name: "checksums.txt"}},
|
||||
})
|
||||
})
|
||||
|
||||
stateDir := t.TempDir()
|
||||
store := checkpoint.NewStore(stateDir)
|
||||
rs, err := store.Create("", "latest")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create: %v", err)
|
||||
}
|
||||
|
||||
checker := New(Options{
|
||||
BinaryPath: binaryPath,
|
||||
ConfigPath: configPath,
|
||||
DataDir: dataDir,
|
||||
TargetVersion: "latest",
|
||||
})
|
||||
|
||||
// -- first run: everything should execute -----------------------------
|
||||
report, err := checker.Run(context.Background(), store, rs)
|
||||
if err != nil {
|
||||
t.Fatalf("Run #1: %v", err)
|
||||
}
|
||||
if report.Blocking() {
|
||||
t.Fatalf("Run #1: unexpected blocking report:\n%s", report.String())
|
||||
}
|
||||
if got := countLines(t, counterPath); got != 1 {
|
||||
t.Fatalf("binary invocations after Run #1 = %d, want 1", got)
|
||||
}
|
||||
if rs.SourceVersion != "0.15.5" {
|
||||
t.Errorf("rs.SourceVersion = %q, want 0.15.5", rs.SourceVersion)
|
||||
}
|
||||
if rs.TargetVersion != "0.16.14" {
|
||||
t.Errorf("rs.TargetVersion = %q, want 0.16.14 (resolved from \"latest\")", rs.TargetVersion)
|
||||
}
|
||||
if rs.Topology.StoreBackend != "rocksdb" {
|
||||
t.Errorf("rs.Topology.StoreBackend = %q, want rocksdb", rs.Topology.StoreBackend)
|
||||
}
|
||||
|
||||
foundDirection := false
|
||||
for _, res := range report.Results {
|
||||
if res.Name == "upgrade-direction" {
|
||||
foundDirection = true
|
||||
if !strings.Contains(res.Detail, "major boundary") {
|
||||
t.Errorf("upgrade-direction detail = %q, want mention of the major boundary", res.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundDirection {
|
||||
t.Error("report missing upgrade-direction check")
|
||||
}
|
||||
|
||||
// -- simulate a crash and resume: reload state from disk fresh --------
|
||||
resumed, err := store.Load(rs.RunID)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Load (resume): %v", err)
|
||||
}
|
||||
report2, err := checker.Run(context.Background(), store, resumed)
|
||||
if err != nil {
|
||||
t.Fatalf("Run #2 (resume): %v", err)
|
||||
}
|
||||
if got := countLines(t, counterPath); got != 1 {
|
||||
t.Errorf("binary invocations after resumed Run = %d, want 1 (already-done steps must not re-execute)", got)
|
||||
}
|
||||
if len(report2.Results) != len(report.Results) {
|
||||
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerRunFlagsTooOldSource(t *testing.T) {
|
||||
counterPath := filepath.Join(t.TempDir(), "invocations")
|
||||
binaryPath := writeFakeBinary(t, "0.14.2", counterPath)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
|
||||
})
|
||||
|
||||
store := checkpoint.NewStore(t.TempDir())
|
||||
rs, err := store.Create("", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checker := New(Options{BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest"})
|
||||
|
||||
report, err := checker.Run(context.Background(), store, rs)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !report.Blocking() {
|
||||
t.Fatalf("expected a blocking report for a too-old source version, got:\n%s", report.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerRunFlagsInsufficientDiskSpace(t *testing.T) {
|
||||
counterPath := filepath.Join(t.TempDir(), "invocations")
|
||||
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
|
||||
})
|
||||
|
||||
store := checkpoint.NewStore(t.TempDir())
|
||||
rs, err := store.Create("", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An absurd multiple guarantees the free-space check fails regardless
|
||||
// of how much space the test host actually has free.
|
||||
checker := New(Options{
|
||||
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
|
||||
TargetVersion: "latest", MinFreeMultiple: 1e12,
|
||||
})
|
||||
|
||||
report, err := checker.Run(context.Background(), store, rs)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !report.Blocking() {
|
||||
t.Fatalf("expected a blocking report for insufficient disk space, got:\n%s", report.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerRunCapturesAccountSnapshotWhenAdminURLSet(t *testing.T) {
|
||||
counterPath := filepath.Join(t.TempDir(), "invocations")
|
||||
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
|
||||
})
|
||||
|
||||
// A fake admin server: answers Ping's session-discovery GET, the
|
||||
// impersonated session-discovery GET MailboxSnapshot makes for
|
||||
// [email protected], and the POST /api calls for x:Account/query,
|
||||
// x:Account/get, and Mailbox/get.
|
||||
var apiURL string
|
||||
adminSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
|
||||
user, _, _ := r.BasicAuth()
|
||||
if strings.Contains(user, "%") {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"apiUrl": apiURL,
|
||||
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api":
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
methodCalls := body["methodCalls"].([]any)
|
||||
name := methodCalls[0].([]any)[0].(string)
|
||||
switch name {
|
||||
case "x:Account/query":
|
||||
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
||||
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
|
||||
}})
|
||||
case "x:Account/get":
|
||||
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
||||
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
|
||||
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
|
||||
}}, "g"},
|
||||
}})
|
||||
case "Mailbox/get":
|
||||
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
|
||||
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
|
||||
{"name": "Inbox", "totalEmails": 5},
|
||||
}}, "m"},
|
||||
}})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
apiURL = adminSrv.URL + "/api"
|
||||
defer adminSrv.Close()
|
||||
|
||||
store := checkpoint.NewStore(t.TempDir())
|
||||
rs, err := store.Create("", "latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checker := New(Options{
|
||||
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest",
|
||||
AdminURL: adminSrv.URL, AdminUser: "admin", AdminPassword: "hunter2",
|
||||
})
|
||||
|
||||
report, err := checker.Run(context.Background(), store, rs)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if report.Blocking() {
|
||||
t.Fatalf("unexpected blocking report:\n%s", report.String())
|
||||
}
|
||||
if rs.PreflightSnapshot == nil {
|
||||
t.Fatal("PreflightSnapshot should be populated when AdminURL is set")
|
||||
}
|
||||
if rs.PreflightSnapshot.AccountCount != 1 {
|
||||
t.Errorf("AccountCount = %d, want 1", rs.PreflightSnapshot.AccountCount)
|
||||
}
|
||||
if len(rs.PreflightSnapshot.Domains) != 1 || rs.PreflightSnapshot.Domains[0] != "example.com" {
|
||||
t.Errorf("Domains = %v, want [example.com]", rs.PreflightSnapshot.Domains)
|
||||
}
|
||||
aliceMailboxes := rs.PreflightSnapshot.MailboxCounts["[email protected]"]
|
||||
if len(aliceMailboxes) != 1 || aliceMailboxes[0].Mailbox != "Inbox" || aliceMailboxes[0].Messages != 5 {
|
||||
t.Errorf("alice's mailbox counts = %+v, want [{Inbox 5}]", aliceMailboxes)
|
||||
}
|
||||
|
||||
// Resume: the snapshot must survive a reload from disk without
|
||||
// re-running the check (the admin server would still work, but this
|
||||
// confirms the persisted value is what's actually being relied on).
|
||||
resumed, err := store.Load(rs.RunID)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
if resumed.PreflightSnapshot == nil || resumed.PreflightSnapshot.AccountCount != 1 {
|
||||
t.Errorf("resumed PreflightSnapshot = %+v, want AccountCount 1", resumed.PreflightSnapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LooksClustered does a conservative, heuristic scan for cluster-related
|
||||
// configuration. It exists to force a manual confirmation gate (see
|
||||
// ARCHITECTURE.md §4.1's cluster gate: one live node on the old version
|
||||
// during migration corrupts a shared store), not to enumerate peers
|
||||
// precisely. A false positive just costs an extra confirmation prompt; a
|
||||
// false negative is the dangerous direction, so this errs toward matching
|
||||
// broadly rather than requiring an exact schema match.
|
||||
func LooksClustered(configPath string) (bool, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("preflight: read config %s: %w", configPath, err)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(data)), "cluster"), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLooksClustered(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{"no-cluster", "[server]\nhostname = \"mail.example.com\"\n", false},
|
||||
{"has-cluster-section", "[cluster]\nnode-id = 1\npeers = [\"10.0.0.2\"]\n", true},
|
||||
{"cluster-mentioned-in-key", "[server]\ncluster-coordinator = \"redis://localhost\"\n", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LooksClustered(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LooksClustered: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("LooksClustered(%s) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// DeploymentKind is how a Stalwart instance appears to be run, which
|
||||
// determines how cutover and rollback restart it.
|
||||
type DeploymentKind string
|
||||
|
||||
const (
|
||||
DeploymentSystemd DeploymentKind = "systemd"
|
||||
DeploymentDocker DeploymentKind = "docker"
|
||||
DeploymentUnknown DeploymentKind = "unknown"
|
||||
)
|
||||
|
||||
var systemdUnitPaths = []string{
|
||||
"/etc/systemd/system/stalwart.service",
|
||||
"/lib/systemd/system/stalwart.service",
|
||||
"/usr/lib/systemd/system/stalwart.service",
|
||||
}
|
||||
|
||||
// DetectDeploymentKind makes a best-effort guess at how Stalwart is run
|
||||
// here. It's deliberately conservative and cheap (file stats, one docker
|
||||
// inspect) rather than exhaustive - an operator-supplied override should
|
||||
// always be able to win over this, since the cost of guessing wrong here is
|
||||
// cutover targeting the wrong thing.
|
||||
func DetectDeploymentKind(ctx context.Context, containerName string) DeploymentKind {
|
||||
for _, p := range systemdUnitPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return DeploymentSystemd
|
||||
}
|
||||
}
|
||||
if containerName == "" {
|
||||
containerName = "stalwart"
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err == nil {
|
||||
if err := exec.CommandContext(ctx, "docker", "inspect", containerName).Run(); err == nil {
|
||||
return DeploymentDocker
|
||||
}
|
||||
}
|
||||
return DeploymentUnknown
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// DirSize walks dir and sums the size of every regular file in it. Used to
|
||||
// estimate how much free space a filesystem-level backup copy will need -
|
||||
// see ARCHITECTURE.md §4.2.
|
||||
func DirSize(dir string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("preflight: measure size of %s: %w", dir, err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// FreeBytes returns the free space available (to an unprivileged process)
|
||||
// on the filesystem containing path.
|
||||
func FreeBytes(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, fmt.Errorf("preflight: statfs %s: %w", path, err)
|
||||
}
|
||||
return stat.Bavail * uint64(stat.Bsize), nil
|
||||
}
|
||||
|
||||
func humanBytes(n uint64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n/div >= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.bin"), make([]byte, 100), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "sub")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "b.bin"), make([]byte, 250), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := DirSize(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("DirSize: %v", err)
|
||||
}
|
||||
if got != 350 {
|
||||
t.Errorf("DirSize = %d, want 350", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeBytes(t *testing.T) {
|
||||
got, err := FreeBytes(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("FreeBytes: %v", err)
|
||||
}
|
||||
if got == 0 {
|
||||
t.Error("FreeBytes = 0, want > 0 for a live filesystem")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in uint64
|
||||
want string
|
||||
}{
|
||||
{500, "500 B"},
|
||||
{1536, "1.5 KiB"},
|
||||
{5 * 1024 * 1024, "5.0 MiB"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := humanBytes(tc.in); got != tc.want {
|
||||
t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package preflight implements the read-only preflight checks that gate a run before anything is touched.
|
||||
// See ARCHITECTURE.md §4.1 for the design.
|
||||
package preflight
|
||||
@@ -0,0 +1,74 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// githubAPIBase is a var, not a const, so tests can point it at an
|
||||
// httptest server instead of hitting the real GitHub API.
|
||||
var githubAPIBase = "https://api.github.com/repos/stalwartlabs/stalwart/releases"
|
||||
|
||||
type ReleaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
SizeBytes int64 `json:"size"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []ReleaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// ResolveRelease looks up a Stalwart release from the public GitHub API.
|
||||
// version is either an exact tag like "0.16.14" (a "v" prefix is added if
|
||||
// missing) or "latest".
|
||||
func ResolveRelease(ctx context.Context, httpClient *http.Client, version string) (*Release, error) {
|
||||
url := githubAPIBase + "/latest"
|
||||
if version != "" && version != "latest" {
|
||||
tag := version
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
tag = "v" + tag
|
||||
}
|
||||
url = githubAPIBase + "/tags/" + tag
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preflight: fetch release %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("preflight: fetch release %s: unexpected status %s", url, resp.Status)
|
||||
}
|
||||
var rel Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("preflight: parse release response from %s: %w", url, err)
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// ChecksumAsset returns the release asset that looks like a published
|
||||
// checksum manifest, if any. Its presence isn't guaranteed by Stalwart's
|
||||
// release process, so callers must treat a nil result as "no independently
|
||||
// published checksum to verify the download against", not an error.
|
||||
func ChecksumAsset(rel *Release) *ReleaseAsset {
|
||||
for i := range rel.Assets {
|
||||
name := strings.ToLower(rel.Assets[i].Name)
|
||||
if strings.Contains(name, "sha256") || strings.Contains(name, "checksum") {
|
||||
return &rel.Assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withFakeGithub(t *testing.T, handler http.HandlerFunc) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
old := githubAPIBase
|
||||
githubAPIBase = srv.URL
|
||||
t.Cleanup(func() { githubAPIBase = old })
|
||||
}
|
||||
|
||||
func TestResolveReleaseLatest(t *testing.T) {
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/latest" {
|
||||
t.Errorf("path = %s, want /latest", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(Release{
|
||||
TagName: "v0.16.14",
|
||||
Assets: []ReleaseAsset{
|
||||
{Name: "stalwart-x86_64-linux", DownloadURL: "https://example.com/stalwart"},
|
||||
{Name: "checksums.txt", DownloadURL: "https://example.com/checksums.txt"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
rel, err := ResolveRelease(context.Background(), nil, "latest")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRelease: %v", err)
|
||||
}
|
||||
if rel.TagName != "v0.16.14" {
|
||||
t.Errorf("TagName = %s, want v0.16.14", rel.TagName)
|
||||
}
|
||||
if asset := ChecksumAsset(rel); asset == nil || asset.Name != "checksums.txt" {
|
||||
t.Errorf("ChecksumAsset = %+v, want checksums.txt", asset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReleaseExactTag(t *testing.T) {
|
||||
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tags/v0.16.5" {
|
||||
t.Errorf("path = %s, want /tags/v0.16.5", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(Release{TagName: "v0.16.5"})
|
||||
})
|
||||
|
||||
rel, err := ResolveRelease(context.Background(), nil, "0.16.5")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRelease: %v", err)
|
||||
}
|
||||
if rel.TagName != "v0.16.5" {
|
||||
t.Errorf("TagName = %s, want v0.16.5", rel.TagName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumAssetAbsent(t *testing.T) {
|
||||
rel := &Release{Assets: []ReleaseAsset{{Name: "stalwart-x86_64-linux"}}}
|
||||
if asset := ChecksumAsset(rel); asset != nil {
|
||||
t.Errorf("ChecksumAsset = %+v, want nil", asset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Status is a single check's verdict.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusWarn Status = "warn"
|
||||
StatusFail Status = "fail"
|
||||
)
|
||||
|
||||
// CheckResult is one named check's outcome.
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Report is the full set of preflight check outcomes.
|
||||
type Report struct {
|
||||
Results []CheckResult
|
||||
}
|
||||
|
||||
// Blocking reports whether any check failed hard enough that a run must
|
||||
// not proceed.
|
||||
func (r Report) Blocking() bool {
|
||||
for _, res := range r.Results {
|
||||
if res.Status == StatusFail {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r Report) String() string {
|
||||
var b strings.Builder
|
||||
for _, res := range r.Results {
|
||||
fmt.Fprintf(&b, "[%-4s] %-20s %s\n", strings.ToUpper(string(res.Status)), res.Name, res.Detail)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// knownBackends is the set of store/blob/FTS backend identifiers Stalwart's
|
||||
// documentation names: RocksDB, SQLite, FoundationDB, PostgreSQL, MySQL,
|
||||
// S3-compatible storage, and Elasticsearch.
|
||||
var knownBackends = map[string]bool{
|
||||
"rocksdb": true, "sqlite": true, "foundationdb": true,
|
||||
"postgresql": true, "mysql": true, "s3": true, "elasticsearch": true,
|
||||
}
|
||||
|
||||
// BackendMatch is one "type = <backend>" assignment found in a config
|
||||
// file, tagged with where it was found (the enclosing TOML section, or the
|
||||
// dotted JSON key path).
|
||||
type BackendMatch struct {
|
||||
Path string
|
||||
Backend string
|
||||
}
|
||||
|
||||
// DetectStoreBackends scans a Stalwart config file for store/blob/FTS
|
||||
// backend declarations. It deliberately does not assume one fixed schema
|
||||
// path: the exact TOML (pre-0.16) or JSON (0.16+) layout has already
|
||||
// changed once between those versions and may change again, so this
|
||||
// searches structurally for `type = "<known backend>"` assignments wherever
|
||||
// they appear and reports every match with its location. Treat the result
|
||||
// as "here's what to confirm before an unattended run", not ground truth.
|
||||
func DetectStoreBackends(configPath string) ([]BackendMatch, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preflight: read config %s: %w", configPath, err)
|
||||
}
|
||||
if json.Valid(data) {
|
||||
return scanJSONBackends(data)
|
||||
}
|
||||
return scanTOMLBackends(data)
|
||||
}
|
||||
|
||||
var (
|
||||
tomlSectionRe = regexp.MustCompile(`^\[(.+)\]$`)
|
||||
tomlKVRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"$`)
|
||||
)
|
||||
|
||||
func scanTOMLBackends(data []byte) ([]BackendMatch, error) {
|
||||
var matches []BackendMatch
|
||||
section := ""
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if m := tomlSectionRe.FindStringSubmatch(line); m != nil {
|
||||
section = m[1]
|
||||
continue
|
||||
}
|
||||
if m := tomlKVRe.FindStringSubmatch(line); m != nil {
|
||||
key, value := m[1], strings.ToLower(m[2])
|
||||
if key == "type" && knownBackends[value] {
|
||||
matches = append(matches, BackendMatch{Path: section, Backend: value})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("preflight: scan toml config: %w", err)
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func scanJSONBackends(data []byte) ([]BackendMatch, error) {
|
||||
var root any
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("preflight: parse json config: %w", err)
|
||||
}
|
||||
var matches []BackendMatch
|
||||
walkJSONForBackends(root, "", &matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func walkJSONForBackends(node any, path string, matches *[]BackendMatch) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if t, ok := v["type"].(string); ok && knownBackends[strings.ToLower(t)] {
|
||||
*matches = append(*matches, BackendMatch{Path: path, Backend: strings.ToLower(t)})
|
||||
}
|
||||
for k, child := range v {
|
||||
childPath := k
|
||||
if path != "" {
|
||||
childPath = path + "." + k
|
||||
}
|
||||
walkJSONForBackends(child, childPath, matches)
|
||||
}
|
||||
case []any:
|
||||
for i, child := range v {
|
||||
walkJSONForBackends(child, fmt.Sprintf("%s[%d]", path, i), matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectStoreBackendsTOML(t *testing.T) {
|
||||
toml := `
|
||||
[server]
|
||||
hostname = "mail.example.com"
|
||||
|
||||
[store."rocksdb"]
|
||||
type = "rocksdb"
|
||||
path = "/var/lib/stalwart/data"
|
||||
|
||||
[store."blob"]
|
||||
type = "s3"
|
||||
bucket = "stalwart-blobs"
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(toml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
matches, err := DetectStoreBackends(path)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectStoreBackends: %v", err)
|
||||
}
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, m := range matches {
|
||||
got[m.Path] = m.Backend
|
||||
}
|
||||
if got[`store."rocksdb"`] != "rocksdb" {
|
||||
t.Errorf(`store."rocksdb" backend = %q, want rocksdb`, got[`store."rocksdb"`])
|
||||
}
|
||||
if got[`store."blob"`] != "s3" {
|
||||
t.Errorf(`store."blob" backend = %q, want s3`, got[`store."blob"`])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectStoreBackendsJSON(t *testing.T) {
|
||||
jsonCfg := `{
|
||||
"store": {
|
||||
"data": {"type": "postgresql", "host": "db.internal"},
|
||||
"blob": {"type": "s3", "bucket": "stalwart-blobs"}
|
||||
}
|
||||
}`
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(jsonCfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
matches, err := DetectStoreBackends(path)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectStoreBackends: %v", err)
|
||||
}
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("got %d matches, want 2: %+v", len(matches), matches)
|
||||
}
|
||||
backends := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
backends[m.Backend] = true
|
||||
}
|
||||
if !backends["postgresql"] || !backends["s3"] {
|
||||
t.Errorf("matches = %+v, want postgresql and s3", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectStoreBackendsNoMatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matches, err := DetectStoreBackends(path)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectStoreBackends: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("got %d matches, want 0: %+v", len(matches), matches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// semver is a minimal major.minor.patch version - enough for this tool's
|
||||
// comparisons since Stalwart's pre-1.0 release tags don't use pre-release
|
||||
// or build metadata.
|
||||
type semver struct {
|
||||
Major, Minor, Patch int
|
||||
}
|
||||
|
||||
var versionPattern = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
|
||||
|
||||
func parseSemver(s string) (semver, error) {
|
||||
m := versionPattern.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return semver{}, fmt.Errorf("preflight: no version number found in %q", s)
|
||||
}
|
||||
major, _ := strconv.Atoi(m[1])
|
||||
minor, _ := strconv.Atoi(m[2])
|
||||
patch, _ := strconv.Atoi(m[3])
|
||||
return semver{major, minor, patch}, nil
|
||||
}
|
||||
|
||||
func (v semver) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) }
|
||||
|
||||
// Compare returns -1, 0, or 1 as v is less than, equal to, or greater than o.
|
||||
func (v semver) Compare(o semver) int {
|
||||
if v.Major != o.Major {
|
||||
return cmp(v.Major, o.Major)
|
||||
}
|
||||
if v.Minor != o.Minor {
|
||||
return cmp(v.Minor, o.Minor)
|
||||
}
|
||||
return cmp(v.Patch, o.Patch)
|
||||
}
|
||||
|
||||
func cmp(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// minSupportedSource is the oldest version this tool will start a migration
|
||||
// from. Stalwart's own upgrade guidance says older installs need to reach
|
||||
// 0.15.x first before crossing the 0.15/0.16 schema boundary this tool
|
||||
// automates - see ARCHITECTURE.md §1/§4.1.
|
||||
var minSupportedSource = semver{0, 15, 0}
|
||||
|
||||
// DetectVersion runs the installed binary's --version flag and extracts a
|
||||
// semver from its output.
|
||||
func DetectVersion(ctx context.Context, binaryPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, binaryPath, "--version")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("preflight: run %s --version: %w (output: %s)", binaryPath, err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
v, err := parseSemver(out.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preflight: parse version from %s --version output %q: %w", binaryPath, strings.TrimSpace(out.String()), err)
|
||||
}
|
||||
return v.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package preflight
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSemver(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"stalwart 0.15.5\n", "0.15.5", false},
|
||||
{"v0.16.14", "0.16.14", false},
|
||||
{"Stalwart Mail Server v0.16.0 (build abc123)", "0.16.0", false},
|
||||
{"no version here", "", true},
|
||||
{"", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseSemver(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseSemver(%q) = %v, want error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseSemver(%q) unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Errorf("parseSemver(%q) = %s, want %s", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemverCompare(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"0.15.5", "0.16.0", -1},
|
||||
{"0.16.14", "0.16.14", 0},
|
||||
{"0.16.1", "0.15.5", 1},
|
||||
{"1.0.0", "0.16.14", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
a, err := parseSemver(tc.a)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSemver(%q): %v", tc.a, err)
|
||||
}
|
||||
b, err := parseSemver(tc.b)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSemver(%q): %v", tc.b, err)
|
||||
}
|
||||
if got := a.Compare(b); got != tc.want {
|
||||
t.Errorf("%s.Compare(%s) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user