Files
Felis/internal/updates/report.go
flyemoji c01f133cd8 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.
2026-07-01 15:59:33 +09:00

55 lines
2.0 KiB
Go

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()
}