`felis update` offers `sudo felis setup` for every planner-backed target and closed with a trailer calling setup idempotent. That is true for velocity -- install_velocity re-resolves the newest build of the pinned minor on each run -- and misleading for felis-api, which both --panel and --plugins resolve to. setup hands bootstrap the binary it is itself running and takes the bootstrap_from_tui arm, which skips the release lookup. The run rebuilds the image and rolls the deployment off that SAME binary: it reports success and leaves the version exactly where it was. An operator following this guidance to apply a felis-api update would watch it appear to work and then see the same version reported again. Only the bootstrap installer moves felis-api, and naming it is where this gets dangerous, so the warning ships with it. The installer is not an updater. Every run re-derives FELIS_ROOT_DOMAIN through detect_node_ip and defaults it to <node-ip>.nip.io; nothing reads the domain back out of the felis.toml an earlier run wrote. A bare re-run -- which is exactly what README documents, with no environment at all -- rewrites root-domain, panel-hostname and admin-hostname to nip.io names, while ensure_panel_tls_cert returns early on the certificate it already wrote and keeps serving the old hostnames. The console then fails to match its own certificate, and there is no re-domain flow to recover with. Persisted secrets are not at risk: load_or_make_secrets sources secrets.env before it generates anything. felis update stays report-only, so no command changed; only the claim about what the offered one accomplishes, and the conditions under which the alternative is safe to run. Tested four ways, because the scoping and the warning are both the point: --panel carries the caveat AND names FELIS_ROOT_DOMAIN, --velocity keeps the ordinary trailer without either, and --mc, which offers no command at all, gets neither.
176 lines
7.3 KiB
Go
176 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/updater"
|
|
"felis.lolicon.best/internal/updates"
|
|
)
|
|
|
|
// planResult builds a Result carrying the given plan, as updater.Runner would.
|
|
func planResult(plan []updates.Action) updater.Result {
|
|
return updater.Result{
|
|
RunResult: updates.RunResult{Plan: plan, SourceErrors: map[string]error{}},
|
|
GatherErrors: map[string]error{},
|
|
}
|
|
}
|
|
|
|
func TestUpdateReportFiltersToSelection(t *testing.T) {
|
|
res := planResult([]updates.Action{
|
|
{Component: "felis-api", Kind: updates.ActionNotify},
|
|
{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true},
|
|
{Component: "k3s", Kind: updates.ActionNotify},
|
|
})
|
|
|
|
all := renderUpdateReport(res, nil)
|
|
for _, want := range []string{"felis-api", "velocity", "k3s"} {
|
|
if !strings.Contains(all, want) {
|
|
t.Fatalf("bare report missing %q:\n%s", want, all)
|
|
}
|
|
}
|
|
|
|
// --velocity must answer about velocity only; leaking k3s into a focused query is
|
|
// the whole reason the selector exists.
|
|
only := renderUpdateReport(res, map[string]bool{"velocity": true})
|
|
if !strings.Contains(only, "velocity") {
|
|
t.Fatalf("selected report missing velocity:\n%s", only)
|
|
}
|
|
if strings.Contains(only, "k3s") || strings.Contains(only, "felis-api") {
|
|
t.Fatalf("selected report leaked unselected components:\n%s", only)
|
|
}
|
|
}
|
|
|
|
// A component that cannot be read must never vanish silently, and "latest unknown"
|
|
// must never stand in for "the release feed was unreachable" — both causes are named.
|
|
func TestUpdateReportNamesBothFailureCauses(t *testing.T) {
|
|
res := planResult(nil)
|
|
res.GatherErrors["velocity"] = errors.New("jar missing")
|
|
res.RunResult.SourceErrors["felis-api"] = errors.New("HTTP 404")
|
|
|
|
out := renderUpdateReport(res, nil)
|
|
if !strings.Contains(out, "current version unreadable") || !strings.Contains(out, "jar missing") {
|
|
t.Fatalf("gather failure not explained:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "latest version undiscoverable") || !strings.Contains(out, "HTTP 404") {
|
|
t.Fatalf("source failure not explained:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestApplyGuidanceUpToDateNeedsForce(t *testing.T) {
|
|
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: true}})
|
|
sel := map[string]bool{"velocity": true}
|
|
|
|
quiet := renderApplyGuidance(res, sel, false)
|
|
if !strings.Contains(quiet, "already up to date") {
|
|
t.Fatalf("want an up-to-date notice:\n%s", quiet)
|
|
}
|
|
if strings.Contains(quiet, "run:") {
|
|
t.Fatalf("must not offer a command for an up-to-date component without --force:\n%s", quiet)
|
|
}
|
|
|
|
forced := renderApplyGuidance(res, sel, true)
|
|
if !strings.Contains(forced, "run:") {
|
|
t.Fatalf("--force must offer the reinstall command:\n%s", forced)
|
|
}
|
|
}
|
|
|
|
// An undiscoverable latest version must never be reported as "up to date". Both
|
|
// states arrive as ActionNone and only LatestKnown separates them, so this is a live
|
|
// confusion, not a hypothetical one — it shipped that way until a smoke test showed
|
|
// `--panel` calling felis-api current right after the release feed returned 404.
|
|
func TestApplyGuidanceUnknownLatestIsNotUpToDate(t *testing.T) {
|
|
res := planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNone, LatestKnown: false}})
|
|
|
|
out := renderApplyGuidance(res, map[string]bool{"velocity": true}, false)
|
|
if strings.Contains(out, "already up to date") {
|
|
t.Fatalf("must not claim currency when the latest version is unknown:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "cannot tell") {
|
|
t.Fatalf("want the uncertainty stated plainly:\n%s", out)
|
|
}
|
|
// One header per selector: the uncertainty is a note under it, not a second block.
|
|
if n := strings.Count(out, "--velocity:"); n != 1 {
|
|
t.Fatalf("want exactly 1 selector header, got %d:\n%s", n, out)
|
|
}
|
|
// The operator asked about this component, so the repair command still belongs.
|
|
if !strings.Contains(out, "run:") {
|
|
t.Fatalf("want the reinstall command offered despite the unknown latest:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Minecraft is pinned and has no planner entry, so --mc explains the pin and offers
|
|
// NO command — and therefore must not print the trailer that explains the command.
|
|
func TestApplyGuidanceMinecraftOffersNoCommand(t *testing.T) {
|
|
out := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
|
|
if !strings.Contains(out, "pinned by policy") {
|
|
t.Fatalf("want the pin explained:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "run:") || strings.Contains(out, "felis setup is idempotent") {
|
|
t.Fatalf("--mc must offer no command and no command trailer:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Every selector in the table must be a real flag on the FlagSet, and every
|
|
// planner-backed selector must name a component the topology actually tracks —
|
|
// otherwise a selector silently matches nothing at runtime.
|
|
func TestUpdateTargetsMatchTopology(t *testing.T) {
|
|
tracked := map[string]bool{}
|
|
for _, s := range updater.Topology() {
|
|
tracked[s.Name] = true
|
|
}
|
|
for _, target := range updateTargets {
|
|
if target.component == "" {
|
|
continue // deliberately untracked (Minecraft is pinned)
|
|
}
|
|
if !tracked[target.component] {
|
|
t.Fatalf("selector --%s maps to %q, which Topology() does not track", target.selector, target.component)
|
|
}
|
|
if target.command == "" {
|
|
t.Fatalf("selector --%s is planner-backed but offers no apply command", target.selector)
|
|
}
|
|
}
|
|
}
|
|
|
|
// `sudo felis setup` is the right answer for velocity and the wrong one for felis-api,
|
|
// so the caveat has to be scoped rather than appended to every run. setup hands
|
|
// bootstrap the binary it is already running, and that arm skips the release lookup:
|
|
// the run rebuilds the image and rolls the deployment off the SAME binary, which looks
|
|
// like a successful update and changes nothing. install_velocity, by contrast, really
|
|
// does re-resolve the newest build on every run.
|
|
func TestApplyGuidanceScopesTheFelisAPICaveat(t *testing.T) {
|
|
const caveat = "cannot install a NEWER felis-api"
|
|
|
|
api := renderApplyGuidance(
|
|
planResult([]updates.Action{{Component: "felis-api", Kind: updates.ActionNotify, LatestKnown: true}}),
|
|
map[string]bool{"panel": true}, false)
|
|
if !strings.Contains(api, caveat) {
|
|
t.Fatalf("--panel resolves to felis-api and must carry the caveat:\n%s", api)
|
|
}
|
|
// Naming the installer without naming this is worse than saying nothing: the README
|
|
// invocation carries no environment, detect_node_ip then defaults FELIS_ROOT_DOMAIN
|
|
// to <node-ip>.nip.io, and the operator re-domains a live install by following our
|
|
// own advice. Nothing reads the previous domain back, and the write-once panel cert
|
|
// keeps the old hostnames, so there is no recovery path either.
|
|
if !strings.Contains(api, "FELIS_ROOT_DOMAIN") {
|
|
t.Fatalf("pointing at the installer without the re-domain warning is a footgun:\n%s", api)
|
|
}
|
|
|
|
vel := renderApplyGuidance(
|
|
planResult([]updates.Action{{Component: "velocity", Kind: updates.ActionNotify, LatestKnown: true}}),
|
|
map[string]bool{"velocity": true}, false)
|
|
if strings.Contains(vel, caveat) {
|
|
t.Fatalf("velocity IS fixed by setup; the caveat would misdirect the operator:\n%s", vel)
|
|
}
|
|
if !strings.Contains(vel, "felis setup is idempotent") {
|
|
t.Fatalf("velocity still wants the ordinary trailer:\n%s", vel)
|
|
}
|
|
|
|
// --mc offers no command at all, so neither trailer belongs.
|
|
mc := renderApplyGuidance(planResult(nil), map[string]bool{"mc": true}, true)
|
|
if strings.Contains(mc, caveat) {
|
|
t.Fatalf("--mc offers no command; the caveat is a non-sequitur:\n%s", mc)
|
|
}
|
|
}
|