detect_node_ip recomputed FELIS_ROOT_DOMAIN from scratch on every run and fell back to <node-ip>.nip.io. Nothing read the domain back out of the felis.toml an earlier run wrote, so it survived only as long as the operator kept passing the same environment. That made re-running the installer destructive on any install with a real domain, and re-running it is not optional: it is the only way to move felis-api to a newer release, which is what `felis update` points operators at. A bare re-run rewrote root_domain, panel_hostname and admin_hostname to nip.io names while ensure_panel_tls_cert returned early on the certificate it had already written, leaving the console serving a cert for hostnames it no longer answered to -- with no re-domain flow to recover through. Precedence is now explicit FELIS_ROOT_DOMAIN, then the domain the last run persisted, then the nip.io default. First installs are unaffected. Deliberate re-domains still work, because there is no other route to one, but they now warn that the write-once certificate is not reissued and that the proxy and login config carry the old name too. Secrets were never exposed to this: load_or_make_secrets has always sourced secrets.env before generating anything. The domain was the one piece of install identity with no read-back. The channel is deliberately left alone. FELIS_VERSION_BOOTSTRAP is not persisted either, but defaulting a re-run to the release channel installs a working build rather than breaking one, so cmd/felis/update.go states that instead. Its warning about the domain went with the bug and would now be false. Verified against the shipped function text: the ladder holds for a fresh host, a re-run with and without the variable set, a re-domain, and a felis.toml whose root_domain is missing or empty. Reverting the one line reproduces the nip.io overwrite.
175 lines
7.2 KiB
Go
175 lines
7.2 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 obliges us to name what a bare re-run still changes. The domain
|
|
// is handled -- detect_node_ip reuses the installed one -- but the channel is not
|
|
// persisted at all and defaults to release, so a host tracking main gets moved onto
|
|
// releases by following this advice.
|
|
if !strings.Contains(api, "FELIS_VERSION_BOOTSTRAP=dev") {
|
|
t.Fatalf("pointing at the installer without the channel caveat misleads a dev host:\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)
|
|
}
|
|
}
|