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
}
+203
View File
@@ -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)
}
}
}
+54
View File
@@ -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()
}
+136
View File
@@ -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
}
+232
View File
@@ -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
}
+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
}
}
+111
View File
@@ -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)
}
}
}