The admin API persists the auto-update maintenance window as lowercase
JSON {"start","end"} (platform_settings key "update_window"), but
updates.Window had no json tags, so it marshaled/unmarshaled with
capitalized keys. The natural decode the update runner will use --
json.Unmarshal(stored, &updates.Window{}) -- would therefore miss every
key and silently yield the zero Window. That fails closed (a zero window
Contains nothing, so notify-only, never a rogue apply), so it is safe but
a latent silent-zero trap for the not-yet-built runner.
Add json:"start"/json:"end" to updates.Window so the obvious decode is
correct by construction; value time.Time treats a stored null as a no-op,
so a cleared/never-set window still decodes to the zero Window. Nothing
in the package serialized Window before, so this changes no existing
behavior.
Guarded by a cross-package contract test in internal/api that marshals
the real api.updateWindow DTO and unmarshals it into updates.Window --
asserting the interval survives (Contains(mid) is true) and that an empty
window decodes to the fail-closed zero Window -- so the two shapes cannot
drift apart silently.
169 lines
7.3 KiB
Go
169 lines
7.3 KiB
Go
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.
|
|
// The json tags are load-bearing across a package boundary: the maintenance window
|
|
// is persisted by internal/api (platform_settings key "update_window") as lowercase
|
|
// {"start","end"}, and the update runner reads those bytes back into a Window. With
|
|
// value (not pointer) time.Time, a stored null unmarshals as a no-op, so a cleared
|
|
// or never-set window decodes to the zero Window — Contains false, fails closed.
|
|
type Window struct {
|
|
Start time.Time `json:"start"`
|
|
End time.Time `json:"end"`
|
|
}
|
|
|
|
// 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
|
|
}
|