Files
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

137 lines
5.6 KiB
Go

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
}