From c01f133cd8e2f9174ae1629394a045525a9cd791 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Wed, 1 Jul 2026 15:59:33 +0900 Subject: [PATCH] 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. --- internal/updates/plan.go | 163 ++++++++++++++++++++++ internal/updates/plan_test.go | 203 +++++++++++++++++++++++++++ internal/updates/report.go | 54 +++++++ internal/updates/seams.go | 136 ++++++++++++++++++ internal/updates/seams_test.go | 232 +++++++++++++++++++++++++++++++ internal/updates/version.go | 183 ++++++++++++++++++++++++ internal/updates/version_test.go | 111 +++++++++++++++ 7 files changed, 1082 insertions(+) create mode 100644 internal/updates/plan.go create mode 100644 internal/updates/plan_test.go create mode 100644 internal/updates/report.go create mode 100644 internal/updates/seams.go create mode 100644 internal/updates/seams_test.go create mode 100644 internal/updates/version.go create mode 100644 internal/updates/version_test.go diff --git a/internal/updates/plan.go b/internal/updates/plan.go new file mode 100644 index 0000000..984393c --- /dev/null +++ b/internal/updates/plan.go @@ -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 +} diff --git a/internal/updates/plan_test.go b/internal/updates/plan_test.go new file mode 100644 index 0000000..069af32 --- /dev/null +++ b/internal/updates/plan_test.go @@ -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) + } + } +} diff --git a/internal/updates/report.go b/internal/updates/report.go new file mode 100644 index 0000000..506a782 --- /dev/null +++ b/internal/updates/report.go @@ -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() +} diff --git a/internal/updates/seams.go b/internal/updates/seams.go new file mode 100644 index 0000000..b2f362d --- /dev/null +++ b/internal/updates/seams.go @@ -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 +} diff --git a/internal/updates/seams_test.go b/internal/updates/seams_test.go new file mode 100644 index 0000000..ebf8594 --- /dev/null +++ b/internal/updates/seams_test.go @@ -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 +} diff --git a/internal/updates/version.go b/internal/updates/version.go new file mode 100644 index 0000000..072686d --- /dev/null +++ b/internal/updates/version.go @@ -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 + } +} diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go new file mode 100644 index 0000000..69b83e6 --- /dev/null +++ b/internal/updates/version_test.go @@ -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) + } + } +}