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:
flyemoji committed 2026-07-01 15:59:33 +09:00
1 parent e058a64a9b
commit c01f133cd8
7 files changed
+1082

No files matched your search

+183
View File
@@ -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
}
}