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,3 @@
|
||||
// Package plan implements the version-boundary migration plans (ordered step lists) that the engine executes.
|
||||
// See ARCHITECTURE.md §4 for the design.
|
||||
package plan
|
||||
@@ -0,0 +1,122 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// semver is a minimal major.minor.patch version - package-local like the
|
||||
// equivalent in internal/preflight, since this comparison is the only thing
|
||||
// plan needs from it and duplicating ~20 lines keeps phase packages
|
||||
// independent per ARCHITECTURE.md §7.
|
||||
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("plan: 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) }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// PhaseName identifies one phase in an ordered migration plan - the phase
|
||||
// packages this names are internal/preflight, internal/backup,
|
||||
// internal/recovery, internal/rollback (invoked on failure, not part of the
|
||||
// forward list), and internal/validate.
|
||||
type PhaseName string
|
||||
|
||||
const (
|
||||
PhasePreflight PhaseName = "preflight"
|
||||
PhaseBackup PhaseName = "backup"
|
||||
PhaseRecovery PhaseName = "recovery" // only present when crossing the 0.15/0.16 boundary
|
||||
PhaseCutover PhaseName = "cutover"
|
||||
PhaseValidate PhaseName = "validate"
|
||||
)
|
||||
|
||||
// Plan is the ordered list of phases one migration run needs, decided once
|
||||
// from the source and target versions. See ARCHITECTURE.md §4.6: crossing
|
||||
// the 0.15/0.16 boundary needs the full recovery-mode migration (§4.4); a
|
||||
// same-boundary patch bump (every 0.16.1-0.16.14 release so far, per
|
||||
// Stalwart's own changelog) is the fast path with no recovery phase at all.
|
||||
type Plan struct {
|
||||
Phases []PhaseName
|
||||
CrossesMajorBoundary bool
|
||||
SourceVersion string
|
||||
TargetVersion string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// HasPhase reports whether name appears in the plan.
|
||||
func (p *Plan) HasPhase(name PhaseName) bool {
|
||||
for _, ph := range p.Phases {
|
||||
if ph == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decide returns the plan for migrating from sourceVersion to
|
||||
// targetVersion. It refuses (rather than guesses) if the source is already
|
||||
// at or beyond the target, since that's not a migration this tool should
|
||||
// attempt to run.
|
||||
func Decide(sourceVersion, targetVersion string) (*Plan, error) {
|
||||
src, err := parseSemver(sourceVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan: parse source version %q: %w", sourceVersion, err)
|
||||
}
|
||||
tgt, err := parseSemver(targetVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan: parse target version %q: %w", targetVersion, err)
|
||||
}
|
||||
if src.Compare(tgt) >= 0 {
|
||||
return nil, fmt.Errorf("plan: source %s is already at or beyond target %s - nothing to migrate", src, tgt)
|
||||
}
|
||||
|
||||
crosses := src.Major == 0 && src.Minor < 16 && (tgt.Major > 0 || tgt.Minor >= 16)
|
||||
if crosses {
|
||||
return &Plan{
|
||||
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate},
|
||||
CrossesMajorBoundary: true,
|
||||
SourceVersion: src.String(),
|
||||
TargetVersion: tgt.String(),
|
||||
Reason: fmt.Sprintf("%s -> %s crosses the 0.15/0.16 major boundary: full recovery-mode migration required", src, tgt),
|
||||
}, nil
|
||||
}
|
||||
return &Plan{
|
||||
Phases: []PhaseName{PhasePreflight, PhaseBackup, PhaseCutover, PhaseValidate},
|
||||
CrossesMajorBoundary: false,
|
||||
SourceVersion: src.String(),
|
||||
TargetVersion: tgt.String(),
|
||||
Reason: fmt.Sprintf("%s -> %s is a same-boundary patch upgrade: fast path applies (no recovery-mode phase)", src, tgt),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package plan
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecideCrossesMajorBoundary(t *testing.T) {
|
||||
p, err := Decide("0.15.5", "0.16.14")
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if !p.CrossesMajorBoundary {
|
||||
t.Error("CrossesMajorBoundary = false, want true for 0.15.5 -> 0.16.14")
|
||||
}
|
||||
if !p.HasPhase(PhaseRecovery) {
|
||||
t.Errorf("phases %v missing PhaseRecovery", p.Phases)
|
||||
}
|
||||
wantOrder := []PhaseName{PhasePreflight, PhaseBackup, PhaseRecovery, PhaseCutover, PhaseValidate}
|
||||
if len(p.Phases) != len(wantOrder) {
|
||||
t.Fatalf("phases = %v, want %v", p.Phases, wantOrder)
|
||||
}
|
||||
for i, ph := range wantOrder {
|
||||
if p.Phases[i] != ph {
|
||||
t.Errorf("phases[%d] = %s, want %s", i, p.Phases[i], ph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecidePatchBumpFastPath(t *testing.T) {
|
||||
p, err := Decide("0.16.5", "0.16.14")
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if p.CrossesMajorBoundary {
|
||||
t.Error("CrossesMajorBoundary = true, want false for a 0.16.x -> 0.16.x bump")
|
||||
}
|
||||
if p.HasPhase(PhaseRecovery) {
|
||||
t.Errorf("phases %v should not include PhaseRecovery on the fast path", p.Phases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideRefusesNoOp(t *testing.T) {
|
||||
if _, err := Decide("0.16.14", "0.16.14"); err == nil {
|
||||
t.Fatal("Decide should refuse when source == target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideRefusesRegression(t *testing.T) {
|
||||
if _, err := Decide("0.16.14", "0.15.5"); err == nil {
|
||||
t.Fatal("Decide should refuse when source is already beyond target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideFutureMajorCrossesBoundaryToo(t *testing.T) {
|
||||
// A hypothetical 0.15.x -> 1.0.0 jump should still be treated as
|
||||
// crossing the boundary this tool knows how to automate.
|
||||
p, err := Decide("0.15.9", "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if !p.CrossesMajorBoundary {
|
||||
t.Error("CrossesMajorBoundary = false, want true for 0.15.9 -> 1.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideRejectsUnparseableVersions(t *testing.T) {
|
||||
if _, err := Decide("not-a-version", "0.16.14"); err == nil {
|
||||
t.Fatal("Decide should reject an unparseable source version")
|
||||
}
|
||||
if _, err := Decide("0.15.5", "not-a-version"); err == nil {
|
||||
t.Fatal("Decide should reject an unparseable target version")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user