feat(updates): add pure decision core for component self-update
Introduce internal/updates: a pure, I/O-free engine that decides what should happen to each tracked platform component (Felis control-plane, k3s, cloudflared, Velocity) given its current version, the latest discovered upstream, its policy, and the current time. Updates are never force-applied. A component is Pinned (Minecraft, left alone), Notify (a SysAdmin is told and applies out of band), or Scheduled (Felis may apply, but only inside a maintenance window the SysAdmin set). The load-bearing invariants are unit-tested: a pinned component never changes, a downgrade is never proposed, a prerelease is never auto-applied, and an apply happens only inside the window. Version parsing tolerates the real feeds (leading v, k3s +k3s1 build suffix, calendar versions, prerelease tails) and orders by SemVer precedence. ReleaseSource/Notifier/Applier are declared as integration seams and exercised via fakes; this package ships no network, SMTP, or kubectl, and deliberately has no blind k3s-upgrade applier.
This commit is contained in:
7 files changed
+1082
No files matched your search
@@ -0,0 +1,183 @@
|
||||
// Package updates is the pure decision core of Felis's component self-update
|
||||
// subsystem (a user-directed capability over spec V4.1: Felis itself, k3s, and the
|
||||
// off-cluster components should be kept current — while Minecraft servers are left
|
||||
// pinned, "能不动的就别动"). It answers one question deterministically: given each
|
||||
// tracked component's current version, the latest version discovered upstream, its
|
||||
// update policy, and the current time, WHAT should happen — nothing, report it as
|
||||
// pinned, notify a SysAdmin, or apply an update inside a human-set maintenance
|
||||
// window.
|
||||
//
|
||||
// The package is deliberately pure: it performs no I/O. Discovering the latest
|
||||
// version (GitHub Releases / PaperMC), notifying operators (SMTP / in-game), and
|
||||
// applying an update (control-plane image bump, k3s upgrade, cloudflared swap) are
|
||||
// integration seams that live with the caller (see seams.go). Keeping the DECISION
|
||||
// here — with its load-bearing safety invariants (a pinned component NEVER changes,
|
||||
// a downgrade is NEVER proposed, a prerelease is NEVER auto-applied, and an apply
|
||||
// happens ONLY inside the window a SysAdmin explicitly set) — makes those invariants
|
||||
// unit-testable without a cluster, a mailbox, or the network, mirroring how
|
||||
// internal/cfsetup splits its pure core from its ExecRunner.
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is a tolerant semantic version. It is tolerant on purpose: the versions
|
||||
// Felis compares do not come from one clean source. k3s stamps a build suffix
|
||||
// ("v1.30.2+k3s1"), releases are commonly tagged with a leading "v", cloudflared
|
||||
// ships calendar versions ("2024.2.1"), and prereleases carry a "-rc.1" tail. Parse
|
||||
// accepts all of these and Compare orders them by the SemVer 2.0.0 precedence rules
|
||||
// (build metadata after "+" is ignored for ordering; a prerelease sorts BEFORE its
|
||||
// corresponding release).
|
||||
type Version struct {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
// Prerelease is the dot-separated identifier set after "-" (empty for a normal
|
||||
// release). Its presence is what IsPrerelease reports and what makes this version
|
||||
// sort below the same Major.Minor.Patch without a prerelease.
|
||||
Prerelease string
|
||||
// raw preserves the original string so String() round-trips what upstream
|
||||
// actually published (e.g. the "+k3s1" a human needs to see in a report).
|
||||
raw string
|
||||
}
|
||||
|
||||
// Parse reads a tolerant semantic version. It accepts an optional leading "v",
|
||||
// fills missing minor/patch with 0 (so "v2" and "2.0" parse), strips build
|
||||
// metadata after "+" for ordering while preserving it in the raw string, and keeps
|
||||
// any "-prerelease" tail. It fails closed: an unparseable core (non-numeric
|
||||
// major/minor/patch) returns an error rather than a zero Version, so a garbled feed
|
||||
// can never masquerade as version 0.0.0 and trigger a spurious "upgrade".
|
||||
func Parse(s string) (Version, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return Version{}, fmt.Errorf("updates: empty version string")
|
||||
}
|
||||
v := Version{raw: raw}
|
||||
|
||||
core := strings.TrimPrefix(raw, "v")
|
||||
core = strings.TrimPrefix(core, "V")
|
||||
|
||||
// Split off build metadata ("+k3s1"): ignored for precedence per SemVer §10.
|
||||
if i := strings.IndexByte(core, '+'); i >= 0 {
|
||||
core = core[:i]
|
||||
}
|
||||
// Split off the prerelease tail ("-rc.1", "-SNAPSHOT").
|
||||
if i := strings.IndexByte(core, '-'); i >= 0 {
|
||||
v.Prerelease = core[i+1:]
|
||||
core = core[:i]
|
||||
}
|
||||
|
||||
parts := strings.Split(core, ".")
|
||||
if len(parts) == 0 || len(parts) > 3 {
|
||||
return Version{}, fmt.Errorf("updates: %q is not a dotted version", raw)
|
||||
}
|
||||
nums := make([]int, 3)
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("updates: %q has a non-numeric component %q", raw, p)
|
||||
}
|
||||
if n < 0 {
|
||||
return Version{}, fmt.Errorf("updates: %q has a negative component %q", raw, p)
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
v.Major, v.Minor, v.Patch = nums[0], nums[1], nums[2]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// IsPrerelease reports whether the version carries a prerelease tail. Auto-apply is
|
||||
// gated on this being false: Felis tracks stable releases and never bumps a live
|
||||
// component onto an rc/beta/SNAPSHOT on its own.
|
||||
func (v Version) IsPrerelease() bool { return v.Prerelease != "" }
|
||||
|
||||
// String returns the original published string when known (so "+k3s1" survives into
|
||||
// a report), falling back to the reconstructed core.
|
||||
func (v Version) String() string {
|
||||
if v.raw != "" {
|
||||
return v.raw
|
||||
}
|
||||
base := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
if v.Prerelease != "" {
|
||||
return base + "-" + v.Prerelease
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// Compare returns -1, 0, or +1 as v sorts before, equal to, or after o, by SemVer
|
||||
// 2.0.0 precedence: numeric Major.Minor.Patch first, then — for an equal core — a
|
||||
// version WITH a prerelease sorts below one without, and two prereleases compare by
|
||||
// their dot-separated identifiers (numeric identifiers numerically, others
|
||||
// lexically; a numeric identifier always sorts below an alphanumeric one). Build
|
||||
// metadata is not consulted.
|
||||
func (v Version) Compare(o Version) int {
|
||||
if c := cmpInt(v.Major, o.Major); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmpInt(v.Minor, o.Minor); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmpInt(v.Patch, o.Patch); c != 0 {
|
||||
return c
|
||||
}
|
||||
return comparePrerelease(v.Prerelease, o.Prerelease)
|
||||
}
|
||||
|
||||
// After reports whether v is strictly newer than o. It is the single predicate the
|
||||
// plan engine uses to decide there is anything to do, so "no downgrade is ever
|
||||
// proposed" reduces to "we only act when After is true".
|
||||
func (v Version) After(o Version) bool { return v.Compare(o) > 0 }
|
||||
|
||||
// comparePrerelease implements SemVer §11.4: an empty prerelease (a release) has
|
||||
// HIGHER precedence than any non-empty one.
|
||||
func comparePrerelease(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if a == "" {
|
||||
return 1 // release > prerelease
|
||||
}
|
||||
if b == "" {
|
||||
return -1 // prerelease < release
|
||||
}
|
||||
ai, bi := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(ai) && i < len(bi); i++ {
|
||||
if c := comparePrereleaseIdent(ai[i], bi[i]); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
// All shared identifiers equal: the longer set has higher precedence (§11.4.4).
|
||||
return cmpInt(len(ai), len(bi))
|
||||
}
|
||||
|
||||
// comparePrereleaseIdent compares two prerelease identifiers: both numeric ⇒
|
||||
// numeric compare; a numeric identifier sorts BELOW an alphanumeric one; otherwise
|
||||
// ASCII lexical.
|
||||
func comparePrereleaseIdent(a, b string) int {
|
||||
an, aerr := strconv.Atoi(a)
|
||||
bn, berr := strconv.Atoi(b)
|
||||
switch {
|
||||
case aerr == nil && berr == nil:
|
||||
return cmpInt(an, bn)
|
||||
case aerr == nil: // a numeric, b not ⇒ a lower
|
||||
return -1
|
||||
case berr == nil: // b numeric, a not ⇒ a higher
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user