fix(update): say that setup cannot move felis-api to a newer release

`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.

The report now says so, scoped to runs that actually offered a felis-api
target, and points at the bootstrap installer -- the path that resolves and
downloads a release. felis update stays report-only, so no command changed;
only the claim about what the offered one accomplishes.

Tested three ways, because the scoping is the whole point: --panel carries the
caveat, --velocity keeps the ordinary trailer without it, and --mc, which
offers no command at all, gets neither.
This commit is contained in:
flyemoji committed 2026-07-20 19:53:55 +09:00
1 parent 8675cda001
commit 8b25114fb4
2 files changed
+45

No files matched your search

+12
View File
@@ -219,6 +219,7 @@ func renderApplyGuidance(res updater.Result, selected map[string]bool, force boo
var b strings.Builder
var offeredCommand bool
var offeredFelisAPI bool
for _, t := range updateTargets {
if !selected[t.selector] {
continue
@@ -247,11 +248,22 @@ func renderApplyGuidance(res updater.Result, selected map[string]bool, force boo
}
fmt.Fprintf(&b, " run: %s\n", t.command)
offeredCommand = true
offeredFelisAPI = offeredFelisAPI || t.component == "felis-api"
}
// Only explain the command when one was actually offered; a --mc-only run has
// nothing to run and the trailer would be a non-sequitur.
if offeredCommand {
b.WriteString("\nfelis setup is idempotent and re-runs the installer that owns these components;\nit does not reinstall what is already current. Restart game servers afterwards.\n")
}
// Scoped to felis-api because it is the only component setup cannot move forward.
// velocity is fine: install_velocity re-resolves the newest build of the pinned minor
// on every run. But setup hands deploy/bootstrap.sh the binary it is itself running
// (FELIS_BOOTSTRAP_BINARY), and that arm skips the release lookup entirely, so it
// rebuilds the image and rolls the deployment from the SAME binary -- a run that looks
// like a successful update and leaves the version unchanged. Only the installer, which
// resolves and downloads a release, actually moves felis-api.
if offeredFelisAPI {
b.WriteString("\nfelis-api (panel, plugins) is the exception: setup re-images it from the felis binary\nalready on this host, so it cannot install a NEWER felis-api. To move to a newer\nrelease, re-run the bootstrap installer (see README).\n")
}
return b.String()
}
+33
View File
@@ -132,3 +132,36 @@ func TestUpdateTargetsMatchTopology(t *testing.T) {
}
}
}
// `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)
}
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)
}
}