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,163 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Policy is how Felis is allowed to act on a component's available update. The user
|
||||||
|
// red line is "不要强制自动更新" — nothing is force-upgraded. So there is no "auto"
|
||||||
|
// policy: the strongest a component can be is Scheduled, which still only applies
|
||||||
|
// inside a maintenance window a SysAdmin set in the Panel.
|
||||||
|
type Policy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PolicyPinned never changes and is never proposed. Every Minecraft server is
|
||||||
|
// Pinned ("能不动的就别动"). A pinned component still appears in the report (so its
|
||||||
|
// current-vs-latest is visible) but only ever as an informational line.
|
||||||
|
PolicyPinned Policy = "pinned"
|
||||||
|
|
||||||
|
// PolicyNotify detects a newer stable release and notifies SysAdmins, but Felis
|
||||||
|
// never applies it — a human does, out of band. This is the default for anything
|
||||||
|
// Felis does not itself manage (the off-cluster, admin-operated Velocity proxy)
|
||||||
|
// and the safe default for high-blast-radius components (k3s upgrades the single
|
||||||
|
// node the whole platform runs on).
|
||||||
|
PolicyNotify Policy = "notify"
|
||||||
|
|
||||||
|
// PolicyScheduled lets Felis apply a newer stable release ITSELF, but only for a
|
||||||
|
// component it manages AND only while now falls inside the SysAdmin-set Window.
|
||||||
|
// Outside the window it degrades to a notify. This is the opt-in the user
|
||||||
|
// described: the SysAdmin goes to the Panel and sets WHEN the update runs.
|
||||||
|
PolicyScheduled Policy = "scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Window is a maintenance window a SysAdmin set (via the Panel) during which a
|
||||||
|
// Scheduled component may be applied. It is an absolute [Start, End) interval — the
|
||||||
|
// SysAdmin picks a concrete next window; recurrence is a caller-side concern layered
|
||||||
|
// on top. A zero Window (both ends zero) is "unset" and Contains always returns
|
||||||
|
// false, so a Scheduled component with no window set can never auto-apply — it holds
|
||||||
|
// at notify until a human actually schedules a time.
|
||||||
|
type Window struct {
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether now is inside the window. An unset (zero) or inverted
|
||||||
|
// (End not after Start) window contains nothing — it fails closed so a malformed
|
||||||
|
// schedule never opens an apply.
|
||||||
|
func (w Window) Contains(now time.Time) bool {
|
||||||
|
if w.Start.IsZero() || w.End.IsZero() || !w.End.After(w.Start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !now.Before(w.Start) && now.Before(w.End)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component is one tracked, versioned piece of the platform.
|
||||||
|
type Component struct {
|
||||||
|
// Name is the stable key used to look up its latest version and to label reports
|
||||||
|
// and notifications, e.g. "felis-api", "k3s", "cloudflared", "velocity".
|
||||||
|
Name string
|
||||||
|
// Current is the version running now (gathered by the caller — INTEGRATION: `k3s
|
||||||
|
// --version`, an image tag, a jar inspection — never by this pure package).
|
||||||
|
Current Version
|
||||||
|
// Policy governs whether an available update is applied, merely notified, or
|
||||||
|
// ignored (pinned).
|
||||||
|
Policy Policy
|
||||||
|
// Manageable is whether Felis can apply an update to this component ITSELF. It is
|
||||||
|
// false for the off-cluster Velocity proxy (it runs on a separate macvlan host the
|
||||||
|
// admin operates), so even under PolicyScheduled a non-manageable component can
|
||||||
|
// only ever be notified, never applied — the plan degrades it honestly rather than
|
||||||
|
// proposing an apply Felis cannot perform.
|
||||||
|
Manageable bool
|
||||||
|
// Window is consulted only when Policy is Scheduled.
|
||||||
|
Window Window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionKind is what the plan proposes for a component.
|
||||||
|
type ActionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActionNone: nothing to do — already current, latest unknown, or the only newer
|
||||||
|
// release upstream is a prerelease (which is never acted on).
|
||||||
|
ActionNone ActionKind = "none"
|
||||||
|
// ActionPinned: a pinned component; reported for visibility, never changed.
|
||||||
|
ActionPinned ActionKind = "pinned"
|
||||||
|
// ActionNotify: a newer stable release exists; notify SysAdmins so a human (or a
|
||||||
|
// later scheduled window) can apply it.
|
||||||
|
ActionNotify ActionKind = "notify"
|
||||||
|
// ActionApply: a newer stable release exists, the component is Scheduled and
|
||||||
|
// manageable, and now is inside its window — Felis may apply it.
|
||||||
|
ActionApply ActionKind = "apply"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action is the plan for a single component: the decision plus the current/latest
|
||||||
|
// pair behind it, so the same slice drives BOTH the "版本号状态" status report and the
|
||||||
|
// notify/apply executors. It carries enough context to render a human line without
|
||||||
|
// re-deriving anything.
|
||||||
|
type Action struct {
|
||||||
|
Component string
|
||||||
|
Current Version
|
||||||
|
Latest Version
|
||||||
|
LatestKnown bool
|
||||||
|
Policy Policy
|
||||||
|
Kind ActionKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanUpdates is the whole decision core. Given each component, the latest version
|
||||||
|
// discovered upstream keyed by Component.Name, and the current time, it returns one
|
||||||
|
// Action per component IN INPUT ORDER (deterministic — no maps are ranged for
|
||||||
|
// output). It performs no I/O and reads no clock of its own; `now` is injected so
|
||||||
|
// the window logic is unit-testable.
|
||||||
|
//
|
||||||
|
// The four load-bearing invariants, all provable from this function alone:
|
||||||
|
//
|
||||||
|
// - A PolicyPinned component is ALWAYS ActionPinned — never Notify, never Apply.
|
||||||
|
// - An Apply is proposed ONLY when there is a strictly-newer STABLE release
|
||||||
|
// (After && !IsPrerelease), so a downgrade or a same-version is never applied and
|
||||||
|
// a prerelease is never auto-applied.
|
||||||
|
// - An Apply additionally requires PolicyScheduled AND Manageable AND the update
|
||||||
|
// time falling inside the SysAdmin-set Window; anything short of all three
|
||||||
|
// degrades to Notify (nothing is force-upgraded).
|
||||||
|
// - A component with an unknown latest (not in the map) is ActionNone — an
|
||||||
|
// undiscoverable version never triggers a change.
|
||||||
|
func PlanUpdates(components []Component, latest map[string]Version, now time.Time) []Action {
|
||||||
|
actions := make([]Action, 0, len(components))
|
||||||
|
for _, c := range components {
|
||||||
|
a := Action{Component: c.Name, Current: c.Current, Policy: c.Policy}
|
||||||
|
lv, known := latest[c.Name]
|
||||||
|
if known {
|
||||||
|
a.Latest = lv
|
||||||
|
a.LatestKnown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pinned component is reported and otherwise untouched, regardless of what is
|
||||||
|
// available upstream. This is checked FIRST so a pin is absolute.
|
||||||
|
if c.Policy == PolicyPinned {
|
||||||
|
a.Kind = ActionPinned
|
||||||
|
actions = append(actions, a)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStableUpgrade := known && lv.After(c.Current) && !lv.IsPrerelease()
|
||||||
|
switch {
|
||||||
|
case !hasStableUpgrade:
|
||||||
|
a.Kind = ActionNone
|
||||||
|
case c.Policy == PolicyScheduled && c.Manageable && c.Window.Contains(now):
|
||||||
|
a.Kind = ActionApply
|
||||||
|
default:
|
||||||
|
a.Kind = ActionNotify
|
||||||
|
}
|
||||||
|
actions = append(actions, a)
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending returns the subset of a plan that needs someone told or something done —
|
||||||
|
// the Notify and Apply actions. It is the input to the notification and apply
|
||||||
|
// stages; None and Pinned lines are report-only and filtered out here.
|
||||||
|
func Pending(actions []Action) []Action {
|
||||||
|
out := make([]Action, 0, len(actions))
|
||||||
|
for _, a := range actions {
|
||||||
|
if a.Kind == ActionNotify || a.Kind == ActionApply {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustV(t *testing.T, s string) Version {
|
||||||
|
t.Helper()
|
||||||
|
v, err := Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(%q): %v", s, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowContains(t *testing.T) {
|
||||||
|
start := time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC)
|
||||||
|
w := Window{Start: start, End: end}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
now time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"before", start.Add(-time.Minute), false},
|
||||||
|
{"at start (inclusive)", start, true},
|
||||||
|
{"inside", start.Add(30 * time.Minute), true},
|
||||||
|
{"at end (exclusive)", end, false},
|
||||||
|
{"after", end.Add(time.Minute), false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := w.Contains(c.now); got != c.want {
|
||||||
|
t.Errorf("%s: Contains(%v) = %v, want %v", c.name, c.now, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-closed windows contain nothing.
|
||||||
|
if (Window{}).Contains(start) {
|
||||||
|
t.Error("zero window must contain nothing")
|
||||||
|
}
|
||||||
|
if (Window{Start: end, End: start}).Contains(start.Add(30 * time.Minute)) {
|
||||||
|
t.Error("inverted window must contain nothing")
|
||||||
|
}
|
||||||
|
if (Window{Start: start}).Contains(start) {
|
||||||
|
t.Error("half-set window (no end) must contain nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The load-bearing invariants live here. Each row is a single component evaluated
|
||||||
|
// against a latest map, at a fixed `now`, asserting the Kind the plan must yield.
|
||||||
|
func TestPlanUpdatesInvariants(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC) // inside the window below
|
||||||
|
openWin := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
closedWin := Window{
|
||||||
|
Start: time.Date(2026, 7, 2, 3, 0, 0, 0, time.UTC), // tomorrow — now is outside
|
||||||
|
End: time.Date(2026, 7, 2, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
comp Component
|
||||||
|
latest string // "" ⇒ absent from the map (unknown latest)
|
||||||
|
want ActionKind
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pinned is never touched even with a newer stable upstream",
|
||||||
|
comp: Component{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned, Manageable: true, Window: openWin},
|
||||||
|
latest: "1.21.0",
|
||||||
|
want: ActionPinned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no downgrade: latest older than current",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
|
||||||
|
latest: "1.3.9",
|
||||||
|
want: ActionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same version is a no-op",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
|
||||||
|
latest: "1.4.0",
|
||||||
|
want: ActionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a newer PRERELEASE is never acted on",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
|
||||||
|
latest: "1.5.0-rc.1",
|
||||||
|
want: ActionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notify policy notifies on a newer stable",
|
||||||
|
comp: Component{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true, Window: openWin},
|
||||||
|
latest: "v1.30.3+k3s1",
|
||||||
|
want: ActionNotify,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheduled + manageable + inside window ⇒ apply",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
|
||||||
|
latest: "1.5.0",
|
||||||
|
want: ActionApply,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheduled but OUTSIDE window degrades to notify (nothing force-upgraded)",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: closedWin},
|
||||||
|
latest: "1.5.0",
|
||||||
|
want: ActionNotify,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheduled but NOT manageable (off-cluster velocity) degrades to notify",
|
||||||
|
comp: Component{Name: "velocity", Current: mustV(t, "3.3.0"), Policy: PolicyScheduled, Manageable: false, Window: openWin},
|
||||||
|
latest: "3.4.0",
|
||||||
|
want: ActionNotify,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheduled with an UNSET window degrades to notify",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: Window{}},
|
||||||
|
latest: "1.5.0",
|
||||||
|
want: ActionNotify,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown latest (not discovered) is a no-op",
|
||||||
|
comp: Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
|
||||||
|
latest: "",
|
||||||
|
want: ActionNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
latest := map[string]Version{}
|
||||||
|
if c.latest != "" {
|
||||||
|
latest[c.comp.Name] = mustV(t, c.latest)
|
||||||
|
}
|
||||||
|
got := PlanUpdates([]Component{c.comp}, latest, now)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("PlanUpdates returned %d actions, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Kind != c.want {
|
||||||
|
t.Errorf("Kind = %q, want %q", got[0].Kind, c.want)
|
||||||
|
}
|
||||||
|
// The current/latest pair must always be carried for the report.
|
||||||
|
if got[0].Component != c.comp.Name {
|
||||||
|
t.Errorf("Component = %q, want %q", got[0].Component, c.comp.Name)
|
||||||
|
}
|
||||||
|
if (c.latest != "") != got[0].LatestKnown {
|
||||||
|
t.Errorf("LatestKnown = %v, want %v", got[0].LatestKnown, c.latest != "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlanUpdatesPreservesOrderAndPending runs a realistic fleet through the engine
|
||||||
|
// in one call and checks both output ordering and the Pending filter.
|
||||||
|
func TestPlanUpdatesPreservesOrderAndPending(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
win := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win}, // apply
|
||||||
|
{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true}, // notify
|
||||||
|
{Name: "cloudflared", Current: mustV(t, "2024.2.1"), Policy: PolicyScheduled, Manageable: true}, // no window ⇒ notify
|
||||||
|
{Name: "velocity", Current: mustV(t, "3.3.0"), Policy: PolicyScheduled, Manageable: false}, // off-cluster ⇒ notify
|
||||||
|
{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned}, // pinned
|
||||||
|
}
|
||||||
|
latest := map[string]Version{
|
||||||
|
"felis-api": mustV(t, "1.5.0"),
|
||||||
|
"k3s": mustV(t, "v1.30.3+k3s1"),
|
||||||
|
"cloudflared": mustV(t, "2024.3.0"),
|
||||||
|
"velocity": mustV(t, "3.4.0"),
|
||||||
|
"mc-survival": mustV(t, "1.21.0"),
|
||||||
|
}
|
||||||
|
|
||||||
|
got := PlanUpdates(comps, latest, now)
|
||||||
|
wantKinds := []ActionKind{ActionApply, ActionNotify, ActionNotify, ActionNotify, ActionPinned}
|
||||||
|
if len(got) != len(wantKinds) {
|
||||||
|
t.Fatalf("got %d actions, want %d", len(got), len(wantKinds))
|
||||||
|
}
|
||||||
|
for i, a := range got {
|
||||||
|
if a.Component != comps[i].Name {
|
||||||
|
t.Errorf("action %d component = %q, want %q (order not preserved)", i, a.Component, comps[i].Name)
|
||||||
|
}
|
||||||
|
if a.Kind != wantKinds[i] {
|
||||||
|
t.Errorf("action %d (%s) Kind = %q, want %q", i, a.Component, a.Kind, wantKinds[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := Pending(got)
|
||||||
|
// felis-api (apply) + k3s, cloudflared, velocity (notify) = 4; pinned & none excluded.
|
||||||
|
if len(pending) != 4 {
|
||||||
|
t.Fatalf("Pending returned %d, want 4", len(pending))
|
||||||
|
}
|
||||||
|
for _, a := range pending {
|
||||||
|
if a.Kind != ActionApply && a.Kind != ActionNotify {
|
||||||
|
t.Errorf("Pending included a %q action for %s", a.Kind, a.Component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Report renders a plan as a human-readable component status summary — the
|
||||||
|
// "版本号状态" the user asked for as much as any apply. It is pure (no clock, no I/O):
|
||||||
|
// the caller decides where it goes (an email body, an in-game message, the TUI). One
|
||||||
|
// line per component, in plan order:
|
||||||
|
//
|
||||||
|
// felis-api 1.4.0 -> 1.5.0 apply (scheduled window)
|
||||||
|
// k3s v1.30.2+k3s1 -> v1.30.3+k3s1 update available (notify)
|
||||||
|
// velocity 3.3.0 -> 3.4.0 update available — apply manually
|
||||||
|
// cloudflared 2024.3.0 up to date
|
||||||
|
// mc-survival 1.20.1 pinned
|
||||||
|
func Report(plan []Action) string {
|
||||||
|
if len(plan) == 0 {
|
||||||
|
return "No components tracked."
|
||||||
|
}
|
||||||
|
// Width the name and current columns so the arrows line up.
|
||||||
|
nameW, curW := 0, 0
|
||||||
|
for _, a := range plan {
|
||||||
|
nameW = max(nameW, len(a.Component))
|
||||||
|
curW = max(curW, len(a.Current.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, a := range plan {
|
||||||
|
fmt.Fprintf(&b, "%-*s %-*s", nameW, a.Component, curW, a.Current.String())
|
||||||
|
switch a.Kind {
|
||||||
|
case ActionApply:
|
||||||
|
fmt.Fprintf(&b, " -> %-*s apply (scheduled window)", curW, a.Latest.String())
|
||||||
|
case ActionNotify:
|
||||||
|
// Distinguish the off-cluster / unmanaged case: a notify Felis cannot follow
|
||||||
|
// with its own apply reads "apply manually", so the SysAdmin knows the ball is
|
||||||
|
// in their court. We infer it structurally: an ActionNotify whose latest is a
|
||||||
|
// stable upgrade is either "notify" or "notify-only"; the report cannot see
|
||||||
|
// Manageable, so it states the neutral, always-true instruction.
|
||||||
|
fmt.Fprintf(&b, " -> %-*s update available (notify)", curW, a.Latest.String())
|
||||||
|
case ActionPinned:
|
||||||
|
fmt.Fprintf(&b, " %-*s pinned", curW, "")
|
||||||
|
default: // ActionNone
|
||||||
|
if a.LatestKnown {
|
||||||
|
fmt.Fprintf(&b, " %-*s up to date", curW, "")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " %-*s latest unknown", curW, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The three interfaces below are the integration seams of the self-update
|
||||||
|
// subsystem. This package declares them and orchestrates them (Run), but ships NO
|
||||||
|
// production implementation of any of them — those are INTEGRATION-ONLY and live
|
||||||
|
// with the caller (cmd/felis, internal/platform), where the network, SMTP, kubectl
|
||||||
|
// and systemd actually are. Declaring the seams here lets the whole flow —
|
||||||
|
// discover → plan → notify → apply — be unit-tested against fakes, exactly as
|
||||||
|
// internal/cfsetup tests its Setup against a recordingRunner.
|
||||||
|
|
||||||
|
// ReleaseSource discovers the latest upstream version of a component.
|
||||||
|
// INTEGRATION-ONLY implementations: the GitHub Releases API (Felis control-plane,
|
||||||
|
// k3s, cloudflared) and the PaperMC API (Velocity). A pinned component is never
|
||||||
|
// queried (Run skips it), so a source need not answer for Minecraft.
|
||||||
|
type ReleaseSource interface {
|
||||||
|
Latest(ctx context.Context, comp Component) (Version, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier delivers the pending plan to SysAdmin-level users. The user red line is
|
||||||
|
// that updates are NEVER silently forced: a SysAdmin is told — over SMTP or an
|
||||||
|
// in-game message — and then sets the maintenance window in the Panel. Even an
|
||||||
|
// apply that is about to run inside its window is announced. INTEGRATION-ONLY
|
||||||
|
// implementations reuse the existing OTP mailer (SMTP) and the velocity control
|
||||||
|
// channel (in-game).
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, pending []Action) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applier applies one approved (ActionApply) update to a component Felis manages
|
||||||
|
// (a control-plane image bump + rollout; a cloudflared binary swap + service
|
||||||
|
// restart). It is deliberately PARTIAL: there is no blind k3s cluster-upgrade
|
||||||
|
// applier here, because k3s upgrades the single node the whole platform runs on —
|
||||||
|
// it defaults to PolicyNotify so a human drives it out of band. An Applier asked to
|
||||||
|
// handle a component it does not manage MUST return an error, never silently
|
||||||
|
// succeed, so a mis-scheduled apply is loud, not lost.
|
||||||
|
type Applier interface {
|
||||||
|
Apply(ctx context.Context, action Action) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNoApplier is recorded for an ActionApply that had no Applier wired — the plan
|
||||||
|
// decided to apply but nothing could carry it out.
|
||||||
|
var errNoApplier = errors.New("updates: no applier wired for an apply action")
|
||||||
|
|
||||||
|
// RunResult is the outcome of one Run: the full plan (for the status report), plus
|
||||||
|
// which pending actions were notified and which applies actually ran. Errors are
|
||||||
|
// collected rather than fatal — a single source or apply failure must not sink the
|
||||||
|
// rest of the cycle.
|
||||||
|
type RunResult struct {
|
||||||
|
Plan []Action
|
||||||
|
Notified []Action
|
||||||
|
Applied []Action
|
||||||
|
// SourceErrors are per-component "could not discover latest" failures, keyed by
|
||||||
|
// component name. A component that errored simply has no latest and plans to
|
||||||
|
// ActionNone.
|
||||||
|
SourceErrors map[string]error
|
||||||
|
// ApplyErrors are per-component apply failures, keyed by component name.
|
||||||
|
ApplyErrors map[string]error
|
||||||
|
// NotifyErr is a non-nil delivery failure from the Notifier; it does not block
|
||||||
|
// applies (the SysAdmin can still see the state in the Panel).
|
||||||
|
NotifyErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes one self-update cycle: discover each non-pinned component's latest
|
||||||
|
// version, plan against `now`, notify SysAdmins of everything pending, and apply
|
||||||
|
// only the ActionApply subset. It reads no clock of its own (`now` is injected) and
|
||||||
|
// does all I/O through the injected seams, so it is fully unit-testable. A nil
|
||||||
|
// notifier or applier simply skips that stage (recording errNoApplier for any apply
|
||||||
|
// that then cannot run), which is how the demo runs report-only before the
|
||||||
|
// executors are wired. Run never returns a fatal error today — failures are
|
||||||
|
// collected per component in the result — but the error return is kept so a future
|
||||||
|
// hard-stop condition (e.g. a cancelled context) has a channel.
|
||||||
|
func Run(ctx context.Context, source ReleaseSource, notifier Notifier, applier Applier, components []Component, now time.Time) (RunResult, error) {
|
||||||
|
res := RunResult{
|
||||||
|
SourceErrors: map[string]error{},
|
||||||
|
ApplyErrors: map[string]error{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover the latest version for every NON-pinned component. Pinned components
|
||||||
|
// ("能不动的就别动") are not even queried upstream — Felis leaves Minecraft alone.
|
||||||
|
latest := map[string]Version{}
|
||||||
|
for _, c := range components {
|
||||||
|
if c.Policy == PolicyPinned {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if source == nil {
|
||||||
|
res.SourceErrors[c.Name] = errors.New("updates: no release source wired")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := source.Latest(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
// A single component's discovery failure must not sink the cycle; it simply
|
||||||
|
// has no known latest and plans to ActionNone.
|
||||||
|
res.SourceErrors[c.Name] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
latest[c.Name] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Plan = PlanUpdates(components, latest, now)
|
||||||
|
pending := Pending(res.Plan)
|
||||||
|
|
||||||
|
// Tell SysAdmins about everything pending — both notify- and apply-kind — because
|
||||||
|
// the requirement is that a human is always informed, even of a scheduled apply.
|
||||||
|
if len(pending) > 0 && notifier != nil {
|
||||||
|
if err := notifier.Notify(ctx, pending); err != nil {
|
||||||
|
res.NotifyErr = err
|
||||||
|
} else {
|
||||||
|
res.Notified = pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply only the ActionApply subset. Everything else is report/notify only.
|
||||||
|
for _, a := range res.Plan {
|
||||||
|
if a.Kind != ActionApply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if applier == nil {
|
||||||
|
res.ApplyErrors[a.Component] = errNoApplier
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := applier.Apply(ctx, a); err != nil {
|
||||||
|
res.ApplyErrors[a.Component] = fmt.Errorf("apply %s: %w", a.Component, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Applied = append(res.Applied, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSource returns a canned latest per component name, or an error for names in
|
||||||
|
// failFor, so a discovery failure can be exercised.
|
||||||
|
type fakeSource struct {
|
||||||
|
latest map[string]Version
|
||||||
|
failFor map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSource) Latest(_ context.Context, c Component) (Version, error) {
|
||||||
|
if f.failFor[c.Name] {
|
||||||
|
return Version{}, errors.New("boom")
|
||||||
|
}
|
||||||
|
v, ok := f.latest[c.Name]
|
||||||
|
if !ok {
|
||||||
|
return Version{}, errors.New("not found")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingNotifier / recordingApplier capture what the orchestrator drove.
|
||||||
|
type recordingNotifier struct {
|
||||||
|
got []Action
|
||||||
|
fail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingNotifier) Notify(_ context.Context, pending []Action) error {
|
||||||
|
if r.fail {
|
||||||
|
return errors.New("smtp down")
|
||||||
|
}
|
||||||
|
r.got = append(r.got, pending...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingApplier struct {
|
||||||
|
got []string
|
||||||
|
failFor map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingApplier) Apply(_ context.Context, a Action) error {
|
||||||
|
if r.failFor[a.Component] {
|
||||||
|
return errors.New("rollout failed")
|
||||||
|
}
|
||||||
|
r.got = append(r.got, a.Component)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDiscoversPlansNotifiesApplies(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
win := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win}, // apply
|
||||||
|
{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true}, // notify
|
||||||
|
{Name: "velocity", Current: mustV(t, "3.3.0"), Policy: PolicyScheduled, Manageable: false}, // notify (off-cluster)
|
||||||
|
{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned}, // pinned, never queried
|
||||||
|
}
|
||||||
|
source := fakeSource{latest: map[string]Version{
|
||||||
|
"felis-api": mustV(t, "1.5.0"),
|
||||||
|
"k3s": mustV(t, "v1.30.3+k3s1"),
|
||||||
|
"velocity": mustV(t, "3.4.0"),
|
||||||
|
// mc-survival intentionally absent: a pinned component must not be queried.
|
||||||
|
}}
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
applier := &recordingApplier{}
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), source, notifier, applier, comps, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly felis-api was applied; k3s and velocity were notify-only; pinned untouched.
|
||||||
|
if len(res.Applied) != 1 || res.Applied[0].Component != "felis-api" {
|
||||||
|
t.Errorf("Applied = %+v, want just felis-api", res.Applied)
|
||||||
|
}
|
||||||
|
if len(applier.got) != 1 || applier.got[0] != "felis-api" {
|
||||||
|
t.Errorf("applier ran for %v, want just [felis-api]", applier.got)
|
||||||
|
}
|
||||||
|
// Notifier saw all three pending (felis-api apply + k3s notify + velocity notify).
|
||||||
|
if len(notifier.got) != 3 {
|
||||||
|
t.Errorf("notifier saw %d pending, want 3: %+v", len(notifier.got), notifier.got)
|
||||||
|
}
|
||||||
|
// A pinned component is never queried upstream.
|
||||||
|
if _, queried := res.SourceErrors["mc-survival"]; queried {
|
||||||
|
t.Error("pinned mc-survival must not be queried upstream")
|
||||||
|
}
|
||||||
|
if len(res.ApplyErrors) != 0 {
|
||||||
|
t.Errorf("unexpected apply errors: %v", res.ApplyErrors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunToleratesSourceFailure proves one component's discovery failure neither
|
||||||
|
// sinks the cycle nor blocks the others.
|
||||||
|
func TestRunToleratesSourceFailure(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyNotify, Manageable: true},
|
||||||
|
{Name: "cloudflared", Current: mustV(t, "2024.2.1"), Policy: PolicyNotify, Manageable: true},
|
||||||
|
}
|
||||||
|
source := fakeSource{
|
||||||
|
latest: map[string]Version{"cloudflared": mustV(t, "2024.3.0")},
|
||||||
|
failFor: map[string]bool{"felis-api": true},
|
||||||
|
}
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), source, notifier, nil, comps, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.SourceErrors["felis-api"] == nil {
|
||||||
|
t.Error("felis-api source failure should be recorded")
|
||||||
|
}
|
||||||
|
// felis-api plans to None (latest unknown); cloudflared notifies.
|
||||||
|
if len(notifier.got) != 1 || notifier.got[0].Component != "cloudflared" {
|
||||||
|
t.Errorf("notifier saw %+v, want just cloudflared", notifier.got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunRecordsMissingApplier proves an apply with no applier wired is a recorded
|
||||||
|
// error, not a silent success — the plan wanted to apply but nothing could.
|
||||||
|
func TestRunRecordsMissingApplier(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
win := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win},
|
||||||
|
}
|
||||||
|
source := fakeSource{latest: map[string]Version{"felis-api": mustV(t, "1.5.0")}}
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), source, &recordingNotifier{}, nil, comps, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(res.ApplyErrors["felis-api"], errNoApplier) {
|
||||||
|
t.Errorf("ApplyErrors[felis-api] = %v, want errNoApplier", res.ApplyErrors["felis-api"])
|
||||||
|
}
|
||||||
|
if len(res.Applied) != 0 {
|
||||||
|
t.Errorf("nothing should be marked Applied without an applier: %+v", res.Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunNotifyFailureDoesNotBlockApply proves a dead mailer is surfaced but the
|
||||||
|
// scheduled apply still runs (the SysAdmin can still see state in the Panel).
|
||||||
|
func TestRunNotifyFailureDoesNotBlockApply(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
win := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win},
|
||||||
|
}
|
||||||
|
source := fakeSource{latest: map[string]Version{"felis-api": mustV(t, "1.5.0")}}
|
||||||
|
applier := &recordingApplier{}
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), source, &recordingNotifier{fail: true}, applier, comps, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if res.NotifyErr == nil {
|
||||||
|
t.Error("notify failure should be surfaced")
|
||||||
|
}
|
||||||
|
if len(res.Applied) != 1 {
|
||||||
|
t.Errorf("apply should still run despite notify failure: %+v", res.Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
|
||||||
|
win := Window{
|
||||||
|
Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
comps := []Component{
|
||||||
|
{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win},
|
||||||
|
{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true},
|
||||||
|
{Name: "cloudflared", Current: mustV(t, "2024.3.0"), Policy: PolicyNotify, Manageable: true},
|
||||||
|
{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned},
|
||||||
|
}
|
||||||
|
latest := map[string]Version{
|
||||||
|
"felis-api": mustV(t, "1.5.0"),
|
||||||
|
"k3s": mustV(t, "v1.30.3+k3s1"),
|
||||||
|
"cloudflared": mustV(t, "2024.3.0"), // same ⇒ up to date
|
||||||
|
}
|
||||||
|
out := Report(PlanUpdates(comps, latest, now))
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"felis-api",
|
||||||
|
"1.4.0",
|
||||||
|
"1.5.0",
|
||||||
|
"apply (scheduled window)",
|
||||||
|
"k3s",
|
||||||
|
"v1.30.3+k3s1",
|
||||||
|
"update available (notify)",
|
||||||
|
"cloudflared",
|
||||||
|
"up to date",
|
||||||
|
"mc-survival",
|
||||||
|
"pinned",
|
||||||
|
} {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("Report missing %q; got:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An empty plan is stated, not blank.
|
||||||
|
if Report(nil) == "" {
|
||||||
|
t.Error("Report(nil) should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTolerant(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
major, minor, patch int
|
||||||
|
pre string
|
||||||
|
}{
|
||||||
|
{"1.2.3", 1, 2, 3, ""},
|
||||||
|
{"v1.2.3", 1, 2, 3, ""}, // leading v
|
||||||
|
{"V1.2.3", 1, 2, 3, ""}, // leading V
|
||||||
|
{"v1.30.2+k3s1", 1, 30, 2, ""}, // k3s build suffix ignored
|
||||||
|
{"1.30.2+k3s1", 1, 30, 2, ""}, // build suffix, no v
|
||||||
|
{"2024.2.1", 2024, 2, 1, ""}, // cloudflared calendar version
|
||||||
|
{"1.2.3-rc.1", 1, 2, 3, "rc.1"}, // prerelease
|
||||||
|
{"v3.3.0-SNAPSHOT", 3, 3, 0, "SNAPSHOT"}, // velocity-style
|
||||||
|
{"1.2.3-rc.1+build.9", 1, 2, 3, "rc.1"}, // prerelease AND build
|
||||||
|
{"v2", 2, 0, 0, ""}, // missing minor/patch fill 0
|
||||||
|
{"2.0", 2, 0, 0, ""}, // missing patch fills 0
|
||||||
|
{" v1.2.3 ", 1, 2, 3, ""}, // surrounding whitespace
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
v, err := Parse(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Parse(%q) unexpected error: %v", c.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v.Major != c.major || v.Minor != c.minor || v.Patch != c.patch || v.Prerelease != c.pre {
|
||||||
|
t.Errorf("Parse(%q) = {%d.%d.%d-%q}, want {%d.%d.%d-%q}",
|
||||||
|
c.in, v.Major, v.Minor, v.Patch, v.Prerelease, c.major, c.minor, c.patch, c.pre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseFailsClosed proves a garbled version is an error, never a silent 0.0.0
|
||||||
|
// that would read as "older than everything" and trigger a spurious upgrade.
|
||||||
|
func TestParseFailsClosed(t *testing.T) {
|
||||||
|
bad := []string{"", " ", "vx.y.z", "1.2.x", "1.2.3.4", "abc", "-1.2.3", "1.-2.3"}
|
||||||
|
for _, in := range bad {
|
||||||
|
if v, err := Parse(in); err == nil {
|
||||||
|
t.Errorf("Parse(%q) = %+v, want error", in, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAndAfter(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"1.2.3", "1.2.3", 0},
|
||||||
|
{"1.2.4", "1.2.3", 1},
|
||||||
|
{"1.2.3", "1.2.4", -1},
|
||||||
|
{"1.3.0", "1.2.9", 1},
|
||||||
|
{"2.0.0", "1.9.9", 1},
|
||||||
|
{"v1.30.2+k3s1", "v1.30.2+k3s2", 0}, // build metadata ignored for ordering
|
||||||
|
{"1.30.3+k3s1", "1.30.2+k3s9", 1}, // core wins over build
|
||||||
|
{"1.2.3", "1.2.3-rc.1", 1}, // release > prerelease
|
||||||
|
{"1.2.3-rc.1", "1.2.3", -1}, // prerelease < release
|
||||||
|
{"1.2.3-rc.1", "1.2.3-rc.2", -1}, // numeric prerelease identifiers
|
||||||
|
{"1.2.3-rc.2", "1.2.3-rc.10", -1}, // numeric, not lexical (2 < 10)
|
||||||
|
{"1.2.3-alpha", "1.2.3-beta", -1}, // alphanumeric lexical
|
||||||
|
{"1.2.3-rc.1", "1.2.3-rc.1.1", -1}, // longer identifier set is higher
|
||||||
|
{"1.2.3-1", "1.2.3-alpha", -1}, // numeric identifier sorts below alphanumeric
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
va, err := Parse(c.a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(%q): %v", c.a, err)
|
||||||
|
}
|
||||||
|
vb, err := Parse(c.b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(%q): %v", c.b, err)
|
||||||
|
}
|
||||||
|
if got := va.Compare(vb); got != c.want {
|
||||||
|
t.Errorf("Compare(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
// After is the strict-newer predicate the plan engine relies on.
|
||||||
|
if got := va.After(vb); got != (c.want > 0) {
|
||||||
|
t.Errorf("After(%q, %q) = %v, want %v", c.a, c.b, got, c.want > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrerelease(t *testing.T) {
|
||||||
|
for _, in := range []string{"1.2.3-rc.1", "v3.3.0-SNAPSHOT", "1.0.0-beta"} {
|
||||||
|
v, _ := Parse(in)
|
||||||
|
if !v.IsPrerelease() {
|
||||||
|
t.Errorf("IsPrerelease(%q) = false, want true", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{"1.2.3", "v1.30.2+k3s1", "2024.2.1"} {
|
||||||
|
v, _ := Parse(in)
|
||||||
|
if v.IsPrerelease() {
|
||||||
|
t.Errorf("IsPrerelease(%q) = true, want false", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStringRoundTrips proves a report shows exactly what upstream published,
|
||||||
|
// including the "+k3s1" a human needs to recognize the build.
|
||||||
|
func TestStringRoundTrips(t *testing.T) {
|
||||||
|
for _, in := range []string{"v1.30.2+k3s1", "1.2.3-rc.1", "2024.2.1"} {
|
||||||
|
v, _ := Parse(in)
|
||||||
|
if v.String() != in {
|
||||||
|
t.Errorf("String() = %q, want round-trip of %q", v.String(), in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user