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