hub v0.82.0 (R-120): the vouch path REFUSES a golden the fleet has already outrun
The golden's version IS the controller it bakes (build-golden.sh:345 defaults GOLDEN_VERSION to the controller tag), so a golden behind the newest deployed controller means every FRESH install lands on stale application code. On the R-120 occurrence that stale code shipped a customer-facing falsehood: a box from the 0.185.1 golden told a customer whose backup drive had fallen out that the backup was on the same disk as the system -- false, the drive was gone -- and offered a different drive as the remedy. WHY A GATE, NOT A REMINDER. The gap has opened three times: R-111 (golden's agent 17 releases behind), R-115 (agent built and deployed, never published), R-120 (this). The first two were closed by re-baking and remembering; remembering then failed again. R-29 is the standing proof that a check nobody runs is worse than none because it reads as coverage -- hostinstall_gates.py sat RED and uninvoked across three version bumps and hub_confirm_gate.py has never run at all. So the property that matters is not whether a check exists but whether it BLOCKS. - Wired into handleSetArtifacts (internal/web/configs.go), immediately before the only write, on the sole UI path to SetArtifactManifest -- it runs on every vouch without anyone choosing to. A script in scripts/ would have been a fourth orphan. - It REFUSES (operator ruling, 2026-07-30), with a flash naming the remedy. - Signal: store.NewestReportedControllerVersion() over reports.controller_version, SEMVER-compared in Go -- MAX() in SQL ranks 0.99.0 above 0.186.0, a pair this fleet has shipped. No outbound call, no new credential. - Fail-open in exactly two deliberate cases: an empty golden field (clearing the manifest is legitimate) and an unknown fleet version (a new hub must vouch its first golden). NEAR-MISS RECORDED: the first draft read guests.controller_version, a column that exists in the schema and that NOTHING writes -- it would always have seen "" and failed open, i.e. inert, this gate's own failure shape. Caught by grepping for a writer before trusting the column. Blind spot stated rather than papered over: a controller no box has ever run is invisible to this signal. Not the failure that has bitten -- all three instances were deployed-newer-than-baked. 4 tests through the PRODUCTION handler over httptest, never an injected seam. The refusal asserts both the flash and that the manifest was NOT written, because a gate that redirects and saves anyway reads as enforcement while providing none. Red-proof: deleting the block makes the stale golden vouchable and both assertions fail. ROADMAP R-29's audit list now records this as the FIRST enforced gate, so the contrast with its three orphans is kept rather than lost. The orphans are unchanged. Suite rc=0 read separately from this commit.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,3 +1,51 @@
|
|||||||
|
## v0.82.0 — R-120: the vouch path refuses a golden the fleet has already outrun (2026-07-30)
|
||||||
|
|
||||||
|
**The mechanism half of R-120.** The golden's version *is* the controller it bakes
|
||||||
|
(`felhom-agent configs/build-golden.sh:345` defaults `GOLDEN_VERSION` to `${CONTROLLER_IMAGE##*:}`), so a
|
||||||
|
golden left behind the newest deployed controller means every **fresh install** lands on stale
|
||||||
|
application code. On the R-120 occurrence that stale code shipped a customer-facing **falsehood**: a box
|
||||||
|
installed from the 0.185.1 golden told a customer whose backup drive had fallen out that *"the backup is
|
||||||
|
on the same disk as the system"* — false, the drive was gone — and offered a different drive as the
|
||||||
|
remedy. 0.186.0 is the release that made that message true, and no new box had it.
|
||||||
|
|
||||||
|
**Why a gate and not a reminder.** This gap has opened **three times** — **R-111** (the golden's agent 17
|
||||||
|
releases behind), **R-115** (an agent built and deployed but never published), **R-120** (this). The first
|
||||||
|
two were closed by re-baking and remembering; remembering then failed again. And **R-29** is the standing
|
||||||
|
proof that a check nobody runs is *worse* than none, because it reads as coverage:
|
||||||
|
`hostinstall_gates.py` sat RED and invoked by nothing across three version bumps while every report said
|
||||||
|
green, and `hub_confirm_gate.py` has never run at all.
|
||||||
|
|
||||||
|
So the distinguishing property is not *does a check exist* but **does it block**:
|
||||||
|
|
||||||
|
- It lives in **`handleSetArtifacts`** (`internal/web/configs.go`), immediately before the only write —
|
||||||
|
the sole UI path to `store.SetArtifactManifest`. It therefore runs on every vouch **without anyone
|
||||||
|
choosing to run it**. A script in `scripts/` asserting the same fact would have been a fourth orphan.
|
||||||
|
- It **REFUSES** (operator ruling, 2026-07-30), with an operator-legible flash naming the remedy, rather
|
||||||
|
than warning.
|
||||||
|
- Signal: `store.NewestReportedControllerVersion()` — the highest controller version any box has
|
||||||
|
reported, from `reports.controller_version` (the column `SaveReport` denormalises). **Semver-compared
|
||||||
|
in Go, not `MAX()` in SQL**, which would rank 0.99.0 above 0.186.0 — a pair this fleet has actually
|
||||||
|
shipped. No outbound call, no new credential.
|
||||||
|
|
||||||
|
**Fail-open in exactly two cases, both deliberate:** an empty golden field (clearing the manifest is a
|
||||||
|
legitimate act) and an unknown fleet version (a new hub must be able to vouch its first golden).
|
||||||
|
|
||||||
|
**Known blind spot, stated rather than papered over:** a controller no box has ever run is invisible to
|
||||||
|
this signal, so a golden baked behind an *unreleased* controller still passes. That is a real limit, and
|
||||||
|
it is not the failure that has bitten — all three instances were "deployed newer than baked".
|
||||||
|
|
||||||
|
**A near-miss worth recording.** The first draft read `guests.controller_version` — a column that exists
|
||||||
|
in the schema (`store.go:294`) and that **nothing writes**. That gate would always have seen `""` and
|
||||||
|
failed open: inert, i.e. precisely the R-29 shape it exists to prevent. Caught by grepping for a writer
|
||||||
|
before trusting the column.
|
||||||
|
|
||||||
|
**Tests: 4, through the production handler over `httptest`, never an injected seam** — because a gate
|
||||||
|
that can be inert is the thing this gate exists to prevent, and three shipped defects in this project
|
||||||
|
were fully green with the seam disconnected. Refusal asserts **both** the flash **and** that the manifest
|
||||||
|
was not written (a gate that redirects and saves anyway reads as enforcement while providing none);
|
||||||
|
plus the allow cases, both fail-open cases, and the semver-ordering case. Red-proof: deleting the block
|
||||||
|
makes the stale golden vouchable and both refusal assertions fail.
|
||||||
|
|
||||||
## v0.81.0 — E-2: the absent backup target gets its own signal (2026-07-29)
|
## v0.81.0 — E-2: the absent backup target gets its own signal (2026-07-29)
|
||||||
|
|
||||||
**Hub half of E-2, and it ships FIRST by necessity:** an event type the hub does not allowlist makes
|
**Hub half of E-2, and it ships FIRST by necessity:** an event type the hub does not allowlist makes
|
||||||
|
|||||||
@@ -1616,6 +1616,48 @@ func (s *Store) GetGlobalMinControllerVersion() string {
|
|||||||
return s.defaultMinControllerVersion
|
return s.defaultMinControllerVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewestReportedControllerVersion returns the highest controller version ANY customer has reported, or
|
||||||
|
// "" when none has. Semver-ordered in Go, not in SQL: `MAX(controller_version)` would compare lexically
|
||||||
|
// and rank 0.99.0 above 0.186.0 — which is the exact pair this gate has to get right.
|
||||||
|
//
|
||||||
|
// It reads `reports.controller_version`, the column SaveReport denormalises out of every report
|
||||||
|
// (store.go:903). It deliberately does NOT read `guests.controller_version`: that column exists in the
|
||||||
|
// schema (:294) and **nothing writes it**, so a gate keyed on it would always see "" and fail open —
|
||||||
|
// an inert gate, which is the exact class R-29 is about. Verified by grep before writing this.
|
||||||
|
//
|
||||||
|
// R-120's gate signal. The hub cannot ask "what is the newest controller that exists" — it has no
|
||||||
|
// registry credential and makes no outbound call at vouch time — but it does know what the FLEET is
|
||||||
|
// running, and that is the signal that matters: the failure this exists to catch is a golden left
|
||||||
|
// behind a controller **already deployed**. It has happened three times (R-111, R-115, R-120) and on
|
||||||
|
// the R-120 occurrence felhom-pve was reporting 0.186.0 while the manifest vouched a 0.185.1 golden —
|
||||||
|
// exactly the comparison below.
|
||||||
|
//
|
||||||
|
// KNOWN BLIND SPOT, stated rather than papered over: a controller no box has ever run is invisible
|
||||||
|
// here, so a golden baked behind an unreleased controller still passes. That is a real limit and it is
|
||||||
|
// not the failure mode that has bitten — the three instances were all "deployed newer than baked".
|
||||||
|
func (s *Store) NewestReportedControllerVersion() string {
|
||||||
|
rows, err := s.db.Query(`SELECT DISTINCT controller_version FROM reports WHERE controller_version IS NOT NULL AND controller_version != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return "" // unreadable → the gate degrades to "cannot compare", never to a false refusal
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
newest := ""
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if rows.Scan(&v) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newest == "" || semver.Compare(v, newest) > 0 {
|
||||||
|
newest = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest
|
||||||
|
}
|
||||||
|
|
||||||
// GlobalFloorResolution is the full picture of the effective global floor for the operator UI: the
|
// GlobalFloorResolution is the full picture of the effective global floor for the operator UI: the
|
||||||
// resolved value + WHICH source won + both raw inputs. It makes the "a manifest save silently armed
|
// resolved value + WHICH source won + both raw inputs. It makes the "a manifest save silently armed
|
||||||
// a live floor" incident (publish-train 0.81/0.113) permanently visible — the operator can see the
|
// a live floor" incident (publish-train 0.81/0.113) permanently visible — the operator can see the
|
||||||
|
|||||||
@@ -1139,6 +1139,37 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
|
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ── R-120 GATE — refuses a golden older than the controller the fleet is already running ─────────
|
||||||
|
//
|
||||||
|
// WHY THIS IS A GATE AND NOT A SCRIPT. The golden's version IS the controller it bakes
|
||||||
|
// (felhom-agent configs/build-golden.sh: GOLDEN_VERSION defaults to ${CONTROLLER_IMAGE##*:}), so a
|
||||||
|
// golden behind the newest deployed controller means every FRESH install lands on stale
|
||||||
|
// application code. That has happened three times — R-111 (the golden's agent 17 releases behind),
|
||||||
|
// R-115 (an agent built and deployed but never published), R-120 (this: the golden a controller
|
||||||
|
// release behind, shipping a customer-facing FALSEHOOD, since 0.186.0 is what made the
|
||||||
|
// absent-backup-target message true). The first two were closed by re-baking and remembering, and
|
||||||
|
// remembering then failed again — which is why this is enforcement, not a reminder.
|
||||||
|
//
|
||||||
|
// It lives HERE, immediately before the only write, because handleSetArtifacts is the sole UI path
|
||||||
|
// to SetArtifactManifest: it therefore runs without anyone choosing to run it. R-29 is the standing
|
||||||
|
// proof that the alternative does not work — `hostinstall_gates.py` sat RED and invoked by nothing
|
||||||
|
// across three version bumps while every report said green, and `hub_confirm_gate.py` has never run
|
||||||
|
// at all. A check in scripts/ asserting this same fact would have been a fourth orphan.
|
||||||
|
//
|
||||||
|
// It REFUSES rather than warns (operator ruling, 2026-07-30): a non-blocking check reads as coverage
|
||||||
|
// it is not providing, which is R-29's whole finding.
|
||||||
|
//
|
||||||
|
// FAIL-OPEN, deliberately, in exactly two cases: an empty golden field (clearing the manifest is a
|
||||||
|
// legitimate operator act) and an unknown fleet version (no guest has reported one — a brand-new hub
|
||||||
|
// must be able to vouch its first golden). Neither is the drift this catches.
|
||||||
|
if goldenVer != "" {
|
||||||
|
if newest := s.store.NewestReportedControllerVersion(); newest != "" && compareVersions(goldenVer, newest) < 0 {
|
||||||
|
s.logger.Printf("[WARN] artifact vouch REFUSED: golden %s is older than the newest controller the fleet reports (%s) — "+
|
||||||
|
"a fresh install would land on stale application code (R-120)", goldenVer, newest)
|
||||||
|
http.Redirect(w, r, "/configuration?flash=golden_behind_fleet", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
||||||
AgentVersion: agentVer,
|
AgentVersion: agentVer,
|
||||||
AgentSHA256: agentSHA,
|
AgentSHA256: agentSHA,
|
||||||
|
|||||||
@@ -152,3 +152,101 @@ func excerpt(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── R-120: the vouch path REFUSES a golden older than the controller the fleet already runs ──────
|
||||||
|
//
|
||||||
|
// These drive handleSetArtifacts — THE production vouch path, the only UI writer of
|
||||||
|
// SetArtifactManifest — over httptest. Deliberately NOT through an injected seam: the whole point of
|
||||||
|
// this gate is that it cannot be inert, and three shipped defects in this project were fully green
|
||||||
|
// with the seam disconnected. The assertions check BOTH the operator-visible outcome and that the
|
||||||
|
// manifest was not written, because a gate that redirects but still saves is worse than none.
|
||||||
|
|
||||||
|
// RED-PROOF: delete the `goldenVer != ""` gate block in handleSetArtifacts → this fails, because the
|
||||||
|
// stale golden is accepted and the manifest is overwritten.
|
||||||
|
func TestVouchRefusesGoldenBehindFleet(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
// The R-120 situation exactly: a box is running 0.186.0 while the operator vouches a 0.185.1 golden.
|
||||||
|
seedReport(t, st, "demo", "0.186.0")
|
||||||
|
if err := st.SetArtifactManifest(store.ArtifactManifest{
|
||||||
|
AgentVersion: "0.116.0", GoldenVersion: "0.186.0",
|
||||||
|
AgentSHA256: strings.Repeat("a", 64), GoldenSHA256: strings.Repeat("b", 64),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}}
|
||||||
|
form.Set("agent_sha256", strings.Repeat("c", 64))
|
||||||
|
form.Set("golden_sha256", strings.Repeat("d", 64))
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSetArtifacts(w, r)
|
||||||
|
|
||||||
|
if got := w.Header().Get("Location"); !strings.Contains(got, "golden_behind_fleet") {
|
||||||
|
t.Errorf("expected the refusal flash, got Location=%q — the operator would not know it was refused", got)
|
||||||
|
}
|
||||||
|
// The load-bearing half: the manifest must be UNCHANGED.
|
||||||
|
if m := st.GetArtifactManifest(); m.GoldenVersion != "0.186.0" {
|
||||||
|
t.Fatalf("REFUSED but still wrote the manifest: golden=%q, want 0.186.0 untouched. A gate that "+
|
||||||
|
"redirects and saves anyway is worse than no gate — it reads as enforcement.", m.GoldenVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate must not block legitimate vouches, or it gets disabled and becomes another R-29 orphan.
|
||||||
|
func TestVouchAllowsGoldenAtOrAheadOfFleet(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
seedReport(t, st, "demo", "0.186.0")
|
||||||
|
for _, golden := range []string{"0.186.0", "0.187.0"} {
|
||||||
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {golden}}
|
||||||
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
||||||
|
form.Set("golden_sha256", strings.Repeat("b", 64))
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSetArtifacts(w, r)
|
||||||
|
if m := st.GetArtifactManifest(); m.GoldenVersion != golden {
|
||||||
|
t.Errorf("golden %s (>= fleet 0.186.0) should be vouchable, manifest holds %q", golden, m.GoldenVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-open case 1: a hub whose fleet has reported nothing must be able to vouch its first golden.
|
||||||
|
func TestVouchAllowedWhenFleetVersionUnknown(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {"0.185.1"}}
|
||||||
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
||||||
|
form.Set("golden_sha256", strings.Repeat("b", 64))
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSetArtifacts(w, r)
|
||||||
|
if m := st.GetArtifactManifest(); m.GoldenVersion != "0.185.1" {
|
||||||
|
t.Errorf("with no reported fleet version the gate must fail OPEN; manifest holds %q", m.GoldenVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-open case 2: clearing the golden field is a legitimate operator act, not drift.
|
||||||
|
func TestVouchAllowsClearingGolden(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
seedReport(t, st, "demo", "0.186.0")
|
||||||
|
form := url.Values{"agent_version": {"0.116.0"}, "golden_version": {""}}
|
||||||
|
form.Set("agent_sha256", strings.Repeat("a", 64))
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleSetArtifacts(w, r)
|
||||||
|
if got := w.Header().Get("Location"); strings.Contains(got, "golden_behind_fleet") {
|
||||||
|
t.Error("clearing the golden must not trip the gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The comparison must be SEMVER, not lexical — 0.99.0 vs 0.186.0 is the pair that breaks string order,
|
||||||
|
// and it is not hypothetical: the fleet has shipped both 0.9x and 0.18x controllers.
|
||||||
|
func TestNewestReportedControllerIsSemverOrdered(t *testing.T) {
|
||||||
|
_, st := newTestServer(t)
|
||||||
|
seedReport(t, st, "a", "0.99.0")
|
||||||
|
seedReport(t, st, "b", "0.186.0")
|
||||||
|
if got := st.NewestReportedControllerVersion(); got != "0.186.0" {
|
||||||
|
t.Errorf("NewestReportedControllerVersion() = %q, want 0.186.0 — a lexical MAX() would return 0.99.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
{{if eq .Flash "artifact_sha_invalid"}}
|
{{if eq .Flash "artifact_sha_invalid"}}
|
||||||
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if eq .Flash "golden_behind_fleet"}}
|
||||||
|
<div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</div>
|
||||||
|
{{end}}
|
||||||
{{if eq .Flash "pw_changed"}}
|
{{if eq .Flash "pw_changed"}}
|
||||||
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
|
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: hub
|
- name: hub
|
||||||
image: gitea.dooplex.hu/admin/felhom-hub:0.81.0
|
image: gitea.dooplex.hu/admin/felhom-hub:0.82.0
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: http
|
name: http
|
||||||
|
|||||||
Reference in New Issue
Block a user