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.
137 lines
5.6 KiB
Go
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
|
|
}
|