Unverified Commit c01f133c authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent e058a64a
Loading
Loading
Loading
Loading
+163 −0
Changes for internal/updates/plan.go: 163 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updates

import "time"

// Policy is how Felis is allowed to act on a component's available update. The user
// red line is "不要强制自动更新" — nothing is force-upgraded. So there is no "auto"
// policy: the strongest a component can be is Scheduled, which still only applies
// inside a maintenance window a SysAdmin set in the Panel.
type Policy string

const (
	// PolicyPinned never changes and is never proposed. Every Minecraft server is
	// Pinned ("能不动的就别动"). A pinned component still appears in the report (so its
	// current-vs-latest is visible) but only ever as an informational line.
	PolicyPinned Policy = "pinned"

	// PolicyNotify detects a newer stable release and notifies SysAdmins, but Felis
	// never applies it — a human does, out of band. This is the default for anything
	// Felis does not itself manage (the off-cluster, admin-operated Velocity proxy)
	// and the safe default for high-blast-radius components (k3s upgrades the single
	// node the whole platform runs on).
	PolicyNotify Policy = "notify"

	// PolicyScheduled lets Felis apply a newer stable release ITSELF, but only for a
	// component it manages AND only while now falls inside the SysAdmin-set Window.
	// Outside the window it degrades to a notify. This is the opt-in the user
	// described: the SysAdmin goes to the Panel and sets WHEN the update runs.
	PolicyScheduled Policy = "scheduled"
)

// Window is a maintenance window a SysAdmin set (via the Panel) during which a
// Scheduled component may be applied. It is an absolute [Start, End) interval — the
// SysAdmin picks a concrete next window; recurrence is a caller-side concern layered
// on top. A zero Window (both ends zero) is "unset" and Contains always returns
// false, so a Scheduled component with no window set can never auto-apply — it holds
// at notify until a human actually schedules a time.
type Window struct {
	Start time.Time
	End   time.Time
}

// Contains reports whether now is inside the window. An unset (zero) or inverted
// (End not after Start) window contains nothing — it fails closed so a malformed
// schedule never opens an apply.
func (w Window) Contains(now time.Time) bool {
	if w.Start.IsZero() || w.End.IsZero() || !w.End.After(w.Start) {
		return false
	}
	return !now.Before(w.Start) && now.Before(w.End)
}

// Component is one tracked, versioned piece of the platform.
type Component struct {
	// Name is the stable key used to look up its latest version and to label reports
	// and notifications, e.g. "felis-api", "k3s", "cloudflared", "velocity".
	Name string
	// Current is the version running now (gathered by the caller — INTEGRATION: `k3s
	// --version`, an image tag, a jar inspection — never by this pure package).
	Current Version
	// Policy governs whether an available update is applied, merely notified, or
	// ignored (pinned).
	Policy Policy
	// Manageable is whether Felis can apply an update to this component ITSELF. It is
	// false for the off-cluster Velocity proxy (it runs on a separate macvlan host the
	// admin operates), so even under PolicyScheduled a non-manageable component can
	// only ever be notified, never applied — the plan degrades it honestly rather than
	// proposing an apply Felis cannot perform.
	Manageable bool
	// Window is consulted only when Policy is Scheduled.
	Window Window
}

// ActionKind is what the plan proposes for a component.
type ActionKind string

const (
	// ActionNone: nothing to do — already current, latest unknown, or the only newer
	// release upstream is a prerelease (which is never acted on).
	ActionNone ActionKind = "none"
	// ActionPinned: a pinned component; reported for visibility, never changed.
	ActionPinned ActionKind = "pinned"
	// ActionNotify: a newer stable release exists; notify SysAdmins so a human (or a
	// later scheduled window) can apply it.
	ActionNotify ActionKind = "notify"
	// ActionApply: a newer stable release exists, the component is Scheduled and
	// manageable, and now is inside its window — Felis may apply it.
	ActionApply ActionKind = "apply"
)

// Action is the plan for a single component: the decision plus the current/latest
// pair behind it, so the same slice drives BOTH the "版本号状态" status report and the
// notify/apply executors. It carries enough context to render a human line without
// re-deriving anything.
type Action struct {
	Component   string
	Current     Version
	Latest      Version
	LatestKnown bool
	Policy      Policy
	Kind        ActionKind
}

// PlanUpdates is the whole decision core. Given each component, the latest version
// discovered upstream keyed by Component.Name, and the current time, it returns one
// Action per component IN INPUT ORDER (deterministic — no maps are ranged for
// output). It performs no I/O and reads no clock of its own; `now` is injected so
// the window logic is unit-testable.
//
// The four load-bearing invariants, all provable from this function alone:
//
//   - A PolicyPinned component is ALWAYS ActionPinned — never Notify, never Apply.
//   - An Apply is proposed ONLY when there is a strictly-newer STABLE release
//     (After && !IsPrerelease), so a downgrade or a same-version is never applied and
//     a prerelease is never auto-applied.
//   - An Apply additionally requires PolicyScheduled AND Manageable AND the update
//     time falling inside the SysAdmin-set Window; anything short of all three
//     degrades to Notify (nothing is force-upgraded).
//   - A component with an unknown latest (not in the map) is ActionNone — an
//     undiscoverable version never triggers a change.
func PlanUpdates(components []Component, latest map[string]Version, now time.Time) []Action {
	actions := make([]Action, 0, len(components))
	for _, c := range components {
		a := Action{Component: c.Name, Current: c.Current, Policy: c.Policy}
		lv, known := latest[c.Name]
		if known {
			a.Latest = lv
			a.LatestKnown = true
		}

		// A pinned component is reported and otherwise untouched, regardless of what is
		// available upstream. This is checked FIRST so a pin is absolute.
		if c.Policy == PolicyPinned {
			a.Kind = ActionPinned
			actions = append(actions, a)
			continue
		}

		hasStableUpgrade := known && lv.After(c.Current) && !lv.IsPrerelease()
		switch {
		case !hasStableUpgrade:
			a.Kind = ActionNone
		case c.Policy == PolicyScheduled && c.Manageable && c.Window.Contains(now):
			a.Kind = ActionApply
		default:
			a.Kind = ActionNotify
		}
		actions = append(actions, a)
	}
	return actions
}

// Pending returns the subset of a plan that needs someone told or something done —
// the Notify and Apply actions. It is the input to the notification and apply
// stages; None and Pinned lines are report-only and filtered out here.
func Pending(actions []Action) []Action {
	out := make([]Action, 0, len(actions))
	for _, a := range actions {
		if a.Kind == ActionNotify || a.Kind == ActionApply {
			out = append(out, a)
		}
	}
	return out
}
+203 −0
Changes for internal/updates/plan_test.go: 203 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updates

import (
	"testing"
	"time"
)

func mustV(t *testing.T, s string) Version {
	t.Helper()
	v, err := Parse(s)
	if err != nil {
		t.Fatalf("Parse(%q): %v", s, err)
	}
	return v
}

func TestWindowContains(t *testing.T) {
	start := time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC)
	end := time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC)
	w := Window{Start: start, End: end}

	cases := []struct {
		name string
		now  time.Time
		want bool
	}{
		{"before", start.Add(-time.Minute), false},
		{"at start (inclusive)", start, true},
		{"inside", start.Add(30 * time.Minute), true},
		{"at end (exclusive)", end, false},
		{"after", end.Add(time.Minute), false},
	}
	for _, c := range cases {
		if got := w.Contains(c.now); got != c.want {
			t.Errorf("%s: Contains(%v) = %v, want %v", c.name, c.now, got, c.want)
		}
	}

	// Fail-closed windows contain nothing.
	if (Window{}).Contains(start) {
		t.Error("zero window must contain nothing")
	}
	if (Window{Start: end, End: start}).Contains(start.Add(30 * time.Minute)) {
		t.Error("inverted window must contain nothing")
	}
	if (Window{Start: start}).Contains(start) {
		t.Error("half-set window (no end) must contain nothing")
	}
}

// The load-bearing invariants live here. Each row is a single component evaluated
// against a latest map, at a fixed `now`, asserting the Kind the plan must yield.
func TestPlanUpdatesInvariants(t *testing.T) {
	now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)     // inside the window below
	openWin := Window{
		Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
		End:   time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
	}
	closedWin := Window{
		Start: time.Date(2026, 7, 2, 3, 0, 0, 0, time.UTC), // tomorrow — now is outside
		End:   time.Date(2026, 7, 2, 4, 0, 0, 0, time.UTC),
	}

	cases := []struct {
		name    string
		comp    Component
		latest  string // "" ⇒ absent from the map (unknown latest)
		want    ActionKind
	}{
		{
			name:   "pinned is never touched even with a newer stable upstream",
			comp:   Component{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned, Manageable: true, Window: openWin},
			latest: "1.21.0",
			want:   ActionPinned,
		},
		{
			name:   "no downgrade: latest older than current",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
			latest: "1.3.9",
			want:   ActionNone,
		},
		{
			name:   "same version is a no-op",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
			latest: "1.4.0",
			want:   ActionNone,
		},
		{
			name:   "a newer PRERELEASE is never acted on",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
			latest: "1.5.0-rc.1",
			want:   ActionNone,
		},
		{
			name:   "notify policy notifies on a newer stable",
			comp:   Component{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true, Window: openWin},
			latest: "v1.30.3+k3s1",
			want:   ActionNotify,
		},
		{
			name:   "scheduled + manageable + inside window ⇒ apply",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
			latest: "1.5.0",
			want:   ActionApply,
		},
		{
			name:   "scheduled but OUTSIDE window degrades to notify (nothing force-upgraded)",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: closedWin},
			latest: "1.5.0",
			want:   ActionNotify,
		},
		{
			name:   "scheduled but NOT manageable (off-cluster velocity) degrades to notify",
			comp:   Component{Name: "velocity", Current: mustV(t, "3.3.0"), Policy: PolicyScheduled, Manageable: false, Window: openWin},
			latest: "3.4.0",
			want:   ActionNotify,
		},
		{
			name:   "scheduled with an UNSET window degrades to notify",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: Window{}},
			latest: "1.5.0",
			want:   ActionNotify,
		},
		{
			name:   "unknown latest (not discovered) is a no-op",
			comp:   Component{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: openWin},
			latest: "",
			want:   ActionNone,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			latest := map[string]Version{}
			if c.latest != "" {
				latest[c.comp.Name] = mustV(t, c.latest)
			}
			got := PlanUpdates([]Component{c.comp}, latest, now)
			if len(got) != 1 {
				t.Fatalf("PlanUpdates returned %d actions, want 1", len(got))
			}
			if got[0].Kind != c.want {
				t.Errorf("Kind = %q, want %q", got[0].Kind, c.want)
			}
			// The current/latest pair must always be carried for the report.
			if got[0].Component != c.comp.Name {
				t.Errorf("Component = %q, want %q", got[0].Component, c.comp.Name)
			}
			if (c.latest != "") != got[0].LatestKnown {
				t.Errorf("LatestKnown = %v, want %v", got[0].LatestKnown, c.latest != "")
			}
		})
	}
}

// TestPlanUpdatesPreservesOrderAndPending runs a realistic fleet through the engine
// in one call and checks both output ordering and the Pending filter.
func TestPlanUpdatesPreservesOrderAndPending(t *testing.T) {
	now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
	win := Window{
		Start: time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC),
		End:   time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC),
	}
	comps := []Component{
		{Name: "felis-api", Current: mustV(t, "1.4.0"), Policy: PolicyScheduled, Manageable: true, Window: win},   // apply
		{Name: "k3s", Current: mustV(t, "v1.30.2+k3s1"), Policy: PolicyNotify, Manageable: true},                  // notify
		{Name: "cloudflared", Current: mustV(t, "2024.2.1"), Policy: PolicyScheduled, Manageable: true},           // no window ⇒ notify
		{Name: "velocity", Current: mustV(t, "3.3.0"), Policy: PolicyScheduled, Manageable: false},                // off-cluster ⇒ notify
		{Name: "mc-survival", Current: mustV(t, "1.20.1"), Policy: PolicyPinned},                                  // pinned
	}
	latest := map[string]Version{
		"felis-api":   mustV(t, "1.5.0"),
		"k3s":         mustV(t, "v1.30.3+k3s1"),
		"cloudflared": mustV(t, "2024.3.0"),
		"velocity":    mustV(t, "3.4.0"),
		"mc-survival": mustV(t, "1.21.0"),
	}

	got := PlanUpdates(comps, latest, now)
	wantKinds := []ActionKind{ActionApply, ActionNotify, ActionNotify, ActionNotify, ActionPinned}
	if len(got) != len(wantKinds) {
		t.Fatalf("got %d actions, want %d", len(got), len(wantKinds))
	}
	for i, a := range got {
		if a.Component != comps[i].Name {
			t.Errorf("action %d component = %q, want %q (order not preserved)", i, a.Component, comps[i].Name)
		}
		if a.Kind != wantKinds[i] {
			t.Errorf("action %d (%s) Kind = %q, want %q", i, a.Component, a.Kind, wantKinds[i])
		}
	}

	pending := Pending(got)
	// felis-api (apply) + k3s, cloudflared, velocity (notify) = 4; pinned & none excluded.
	if len(pending) != 4 {
		t.Fatalf("Pending returned %d, want 4", len(pending))
	}
	for _, a := range pending {
		if a.Kind != ActionApply && a.Kind != ActionNotify {
			t.Errorf("Pending included a %q action for %s", a.Kind, a.Component)
		}
	}
}
+54 −0
Changes for internal/updates/report.go: 54 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updates

import (
	"fmt"
	"strings"
)

// Report renders a plan as a human-readable component status summary — the
// "版本号状态" the user asked for as much as any apply. It is pure (no clock, no I/O):
// the caller decides where it goes (an email body, an in-game message, the TUI). One
// line per component, in plan order:
//
//	felis-api     1.4.0        -> 1.5.0        apply (scheduled window)
//	k3s           v1.30.2+k3s1 -> v1.30.3+k3s1 update available (notify)
//	velocity      3.3.0        -> 3.4.0        update available — apply manually
//	cloudflared   2024.3.0                     up to date
//	mc-survival   1.20.1                       pinned
func Report(plan []Action) string {
	if len(plan) == 0 {
		return "No components tracked."
	}
	// Width the name and current columns so the arrows line up.
	nameW, curW := 0, 0
	for _, a := range plan {
		nameW = max(nameW, len(a.Component))
		curW = max(curW, len(a.Current.String()))
	}

	var b strings.Builder
	for _, a := range plan {
		fmt.Fprintf(&b, "%-*s  %-*s", nameW, a.Component, curW, a.Current.String())
		switch a.Kind {
		case ActionApply:
			fmt.Fprintf(&b, " -> %-*s  apply (scheduled window)", curW, a.Latest.String())
		case ActionNotify:
			// Distinguish the off-cluster / unmanaged case: a notify Felis cannot follow
			// with its own apply reads "apply manually", so the SysAdmin knows the ball is
			// in their court. We infer it structurally: an ActionNotify whose latest is a
			// stable upgrade is either "notify" or "notify-only"; the report cannot see
			// Manageable, so it states the neutral, always-true instruction.
			fmt.Fprintf(&b, " -> %-*s  update available (notify)", curW, a.Latest.String())
		case ActionPinned:
			fmt.Fprintf(&b, " %-*s  pinned", curW, "")
		default: // ActionNone
			if a.LatestKnown {
				fmt.Fprintf(&b, " %-*s  up to date", curW, "")
			} else {
				fmt.Fprintf(&b, " %-*s  latest unknown", curW, "")
			}
		}
		b.WriteByte('\n')
	}
	return b.String()
}
+136 −0
Changes for internal/updates/seams.go: 136 added lines, 0 removed lines.
Original line number Diff line number Diff line
package updates

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// The three interfaces below are the integration seams of the self-update
// subsystem. This package declares them and orchestrates them (Run), but ships NO
// production implementation of any of them — those are INTEGRATION-ONLY and live
// with the caller (cmd/felis, internal/platform), where the network, SMTP, kubectl
// and systemd actually are. Declaring the seams here lets the whole flow —
// discover → plan → notify → apply — be unit-tested against fakes, exactly as
// internal/cfsetup tests its Setup against a recordingRunner.

// ReleaseSource discovers the latest upstream version of a component.
// INTEGRATION-ONLY implementations: the GitHub Releases API (Felis control-plane,
// k3s, cloudflared) and the PaperMC API (Velocity). A pinned component is never
// queried (Run skips it), so a source need not answer for Minecraft.
type ReleaseSource interface {
	Latest(ctx context.Context, comp Component) (Version, error)
}

// Notifier delivers the pending plan to SysAdmin-level users. The user red line is
// that updates are NEVER silently forced: a SysAdmin is told — over SMTP or an
// in-game message — and then sets the maintenance window in the Panel. Even an
// apply that is about to run inside its window is announced. INTEGRATION-ONLY
// implementations reuse the existing OTP mailer (SMTP) and the velocity control
// channel (in-game).
type Notifier interface {
	Notify(ctx context.Context, pending []Action) error
}

// Applier applies one approved (ActionApply) update to a component Felis manages
// (a control-plane image bump + rollout; a cloudflared binary swap + service
// restart). It is deliberately PARTIAL: there is no blind k3s cluster-upgrade
// applier here, because k3s upgrades the single node the whole platform runs on —
// it defaults to PolicyNotify so a human drives it out of band. An Applier asked to
// handle a component it does not manage MUST return an error, never silently
// succeed, so a mis-scheduled apply is loud, not lost.
type Applier interface {
	Apply(ctx context.Context, action Action) error
}

// errNoApplier is recorded for an ActionApply that had no Applier wired — the plan
// decided to apply but nothing could carry it out.
var errNoApplier = errors.New("updates: no applier wired for an apply action")

// RunResult is the outcome of one Run: the full plan (for the status report), plus
// which pending actions were notified and which applies actually ran. Errors are
// collected rather than fatal — a single source or apply failure must not sink the
// rest of the cycle.
type RunResult struct {
	Plan     []Action
	Notified []Action
	Applied  []Action
	// SourceErrors are per-component "could not discover latest" failures, keyed by
	// component name. A component that errored simply has no latest and plans to
	// ActionNone.
	SourceErrors map[string]error
	// ApplyErrors are per-component apply failures, keyed by component name.
	ApplyErrors map[string]error
	// NotifyErr is a non-nil delivery failure from the Notifier; it does not block
	// applies (the SysAdmin can still see the state in the Panel).
	NotifyErr error
}

// Run executes one self-update cycle: discover each non-pinned component's latest
// version, plan against `now`, notify SysAdmins of everything pending, and apply
// only the ActionApply subset. It reads no clock of its own (`now` is injected) and
// does all I/O through the injected seams, so it is fully unit-testable. A nil
// notifier or applier simply skips that stage (recording errNoApplier for any apply
// that then cannot run), which is how the demo runs report-only before the
// executors are wired. Run never returns a fatal error today — failures are
// collected per component in the result — but the error return is kept so a future
// hard-stop condition (e.g. a cancelled context) has a channel.
func Run(ctx context.Context, source ReleaseSource, notifier Notifier, applier Applier, components []Component, now time.Time) (RunResult, error) {
	res := RunResult{
		SourceErrors: map[string]error{},
		ApplyErrors:  map[string]error{},
	}

	// Discover the latest version for every NON-pinned component. Pinned components
	// ("能不动的就别动") are not even queried upstream — Felis leaves Minecraft alone.
	latest := map[string]Version{}
	for _, c := range components {
		if c.Policy == PolicyPinned {
			continue
		}
		if source == nil {
			res.SourceErrors[c.Name] = errors.New("updates: no release source wired")
			continue
		}
		v, err := source.Latest(ctx, c)
		if err != nil {
			// A single component's discovery failure must not sink the cycle; it simply
			// has no known latest and plans to ActionNone.
			res.SourceErrors[c.Name] = err
			continue
		}
		latest[c.Name] = v
	}

	res.Plan = PlanUpdates(components, latest, now)
	pending := Pending(res.Plan)

	// Tell SysAdmins about everything pending — both notify- and apply-kind — because
	// the requirement is that a human is always informed, even of a scheduled apply.
	if len(pending) > 0 && notifier != nil {
		if err := notifier.Notify(ctx, pending); err != nil {
			res.NotifyErr = err
		} else {
			res.Notified = pending
		}
	}

	// Apply only the ActionApply subset. Everything else is report/notify only.
	for _, a := range res.Plan {
		if a.Kind != ActionApply {
			continue
		}
		if applier == nil {
			res.ApplyErrors[a.Component] = errNoApplier
			continue
		}
		if err := applier.Apply(ctx, a); err != nil {
			res.ApplyErrors[a.Component] = fmt.Errorf("apply %s: %w", a.Component, err)
			continue
		}
		res.Applied = append(res.Applied, a)
	}

	return res, nil
}
+232 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading