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

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