diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index 0713b4ae..60639fb3 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -117,6 +117,19 @@ reconciles only on change and reports which generation it has converged to). **Geo is *not* in the agent's desired state** — it's customer→hub→Cloudflare (§7); the agent never touches WAF. +**The controller floor and its agent requirement (hub v0.112.0, R-472).** The managed controller floor +rides the report ACK. `store.ResolveManagedFloor` decides per box whether to serve it, from three +inputs: the floor in force (per-customer override, else global), the vouched Day-0 manifest (golden +version + MinAgent), and a **declared MinAgent** stored beside the floor +(`hub_settings.min_controller_version_declared_min_agent` for the global floor, +`customer_configs.min_controller_declared_min_agent` for an override, each as `FLOOR=MINAGENT` so it +binds only to the floor it was saved with). Inside the golden the manifest's MinAgent governs. Above +the golden the declared one does; with no declaration the floor is **held beyond the golden**, as since +R-216. Either way the box's reported agent must meet the chosen MinAgent, else the floor is held with +`agent < MinAgent `. The decision carries `MinAgentSource` (`manifest` / `declared`), shown on +the Hosts page and logged once per change as `managed floor SERVED`. The rules the operator follows: +`runbooks/publish-train-rules.md` rule 1. + ## 6. Authorization — signed-op queue + editing flow Implements Part 4's gate on the hub side. The hub holds **no signing key**. diff --git a/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-A-declared-branch-removed.txt b/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-A-declared-branch-removed.txt new file mode 100644 index 00000000..c4c10244 --- /dev/null +++ b/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-A-declared-branch-removed.txt @@ -0,0 +1,13 @@ +MUTATION in internal/store/store.go: + - if beyondGolden && d.DeclaredMinAgent != "" { + d.MinAgent, d.MinAgentSource = d.DeclaredMinAgent, MinAgentSourceDeclared + } + + _ = MinAgentSourceDeclared + +=== RUN TestDeclaredMinAgent_A_AboveGoldenServedWhenAgentMeetsIt + declared_floor_test.go:33: a declared floor above the golden must be SERVED: {Floor: Held:true AgentVersion:0.129.0 RequestedFloor:0.239.0 MinAgent:0.129.0 GoldenVersion:0.236.0 HeldBeyondGolden:true DeclaredMinAgent:0.129.0 MinAgentSource:manifest} (reason "held: floor 0.239.0 is ABOVE the vouched golden 0.236.0, so its agent requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1)") +--- FAIL: TestDeclaredMinAgent_A_AboveGoldenServedWhenAgentMeetsIt (0.02s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/store 0.027s +FAIL +exit=1 diff --git a/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-C-undeclared-served.txt b/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-C-undeclared-served.txt new file mode 100644 index 00000000..2f5c0dcf --- /dev/null +++ b/documentation/audits/rulings-r472-r475-2026-09-13/rp-hub-C-undeclared-served.txt @@ -0,0 +1,11 @@ +MUTATION in internal/store/store.go: + - if beyondGolden && d.MinAgentSource != MinAgentSourceDeclared { + + if false && beyondGolden && d.MinAgentSource != MinAgentSourceDeclared { + +=== RUN TestDeclaredMinAgent_C_UndeclaredAboveGoldenIsHeldExactlyAsBefore + declared_floor_test.go:64: an UNDECLARED floor above the golden must stay held beyond the golden: {Floor:0.239.0 Held:false AgentVersion:0.130.0 RequestedFloor:0.239.0 MinAgent:0.129.0 GoldenVersion:0.236.0 HeldBeyondGolden:false DeclaredMinAgent: MinAgentSource:manifest} +--- FAIL: TestDeclaredMinAgent_C_UndeclaredAboveGoldenIsHeldExactlyAsBefore (0.02s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/store 0.031s +FAIL +exit=1 diff --git a/documentation/runbooks/RUNBOOK-manual-build.md b/documentation/runbooks/RUNBOOK-manual-build.md index aa01358d..51899e12 100644 --- a/documentation/runbooks/RUNBOOK-manual-build.md +++ b/documentation/runbooks/RUNBOOK-manual-build.md @@ -98,6 +98,18 @@ the manifest vouches the target FIRST; any MinAgent must be satisfied fleet-wide boxes below it automatically, and flags them); **save the floor LAST** — the DB row acts immediately on every box below it, on their next report. +**Raise the floor to a release with no golden (hub v0.112.0+, R-472).** Between bakes this is the +normal route — do not hand-deploy. + +1. Build and push the controller image (above). Do not install it on any box. +2. Read the MinAgent from the release's header: `grep -m1 -A3 '^## v' CHANGELOG.md` shows + `**MinAgent: X.Y.Z**`. `controller_gates.py --fast` refuses a release whose newest header lacks it. +3. Hub → Configuration → Managed updates: type `` in the floor field **and** that value in + `min_agent`. Save. Without `min_agent` the form refuses with *"This floor is above the vouched + golden — declare its MinAgent"* and stores nothing. +4. Verify: `sudo kubectl -n felhom-system logs deploy/hub | grep 'managed floor SERVED'` shows + `from "declared"`, and each box logs `SetFloor: floor "…" → ""` within about a minute. + ## 4. Golden image (fresh Day-0 installs) The golden is a pre-baked controller-era guest image built in the **drill VM** on 180 @@ -211,9 +223,9 @@ The full 0.188.0 run, with the observables: `documentation/audits/tester-gate-go because `golden_currency_gate.py` trips on every release by design and the only honest ways past it were a bake or a declared `--no-verify` (thirteen of those by 2026-09-01 — R-404/R-417). **The ruling:** bake on a cadence. The ruling assumed every release would still raise the FLOOR (§4.1 step -5) and reach both demo boxes in ~20 s, with only the golden moving to a cadence. **⚠ CORRECTED THE SAME DAY (R-472): between bakes the floor does NOT carry a release — the hub holds any floor above the vouched golden (publish-train rule 1), so releases between bakes reach the demo boxes only by hand-deploy.** A -release between bakes is hand-deployed per the `felhom-build-deploy` skill, and the floor is left at -the vouched golden. +5) and reach both demo boxes in ~20 s, with only the golden moving to a cadence. **⚠ CORRECTED THE SAME DAY (R-472): the hub held any floor above the vouched golden (publish-train rule 1), so releases between bakes reached the demo boxes only by hand-deploy.** **RESOLVED in hub v0.112.0:** a floor above the golden is served when it carries a +declared MinAgent (§3 "Raise the floor to a release with no golden"). A release between bakes rides +the floor again; only an undeclared floor is still held. **The cadence, and it is a step in a routine, not a memory:** diff --git a/documentation/runbooks/publish-train-rules.md b/documentation/runbooks/publish-train-rules.md index 38c6f910..fb604884 100644 --- a/documentation/runbooks/publish-train-rules.md +++ b/documentation/runbooks/publish-train-rules.md @@ -10,6 +10,24 @@ Publish and vouch the artifacts in the Day-0 manifest **before** any floor movem points at an unvouched (or unpublished) version bricks self-updates: boxes are told to move to a version they cannot verify. (GL-1 supply-chain arc; see the go-live package records.) +**Since hub v0.112.0 (operator ruling 2026-09-13, R-472): the manifest leads the floor inside the +golden; above it, the release's own declared MinAgent does.** A controller image is pulled by tag from +the registry, so a floor above the golden does not need a golden to be delivered — it needs to know +which agent the release requires. The operator declares that with the floor, in the `min_agent` field +next to it, read from the release's controller `CHANGELOG.md` header (`**MinAgent: X.Y.Z**`, pinned by +`controller/scripts/minagent_header_gate.py`). + +- **At or below the vouched golden:** the manifest's MinAgent governs, exactly as before. A declared + value is recorded and not used. +- **Above the golden, declared:** the hub compares each box's agent against the declared MinAgent + (rule 3's comparison, same hold text). Hub log: `managed floor SERVED … from declared`. The Hosts + page marks the box `floor: declared MinAgent`. +- **Above the golden, undeclared:** still **HELD beyond the golden**, unchanged (R-216). Both floor + forms refuse to save such a floor (`floor_needs_min_agent`), so the hold is now a sentence at save + time instead of a silent fleet-wide stop. +- A declaration belongs to the exact floor it was saved with (`FLOOR=MINAGENT`). Moving the floor + without a new declaration never carries the old one forward. + ## 2. The manifest screen carries the LIVE DB floor — save the floor field LAST The hub's Day-0 manifest UI also persists the global floor as a DB row diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 138d436a..f314abc0 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,43 @@ +## v0.112.0 — the floor carries a release without a golden (2026-09-13, R-472) + +**Operator ruling 2026-09-13.** Goldens are baked weekly (R-468), but the hub held every floor above +the vouched golden (publish-train rule 1), so a release between bakes reached no box by floor. A +controller image is pulled by tag and needs no golden to be delivered. What the floor was missing is +the release's agent requirement. **The operator now declares it with the floor.** + +**WHAT CHANGED.** + +- **Storage.** A declared MinAgent is stored beside each floor, as `FLOOR=MINAGENT`, so it binds only to + the floor it was saved with: `hub_settings.min_controller_version_declared_min_agent` (global) and + `customer_configs.min_controller_declared_min_agent` (per-customer, idempotent migration). +- **`store.ResolveManagedFloor`.** Above the golden with a declared, parseable MinAgent, the declared + value is used in the SAME agent comparison (same `held: agent < MinAgent ` text). The decision + gains `DeclaredMinAgent` and `MinAgentSource` (`manifest` / `declared`). **The beyond-golden hold is + kept** for an undeclared floor. At or below the golden the manifest governs and a declaration is + recorded, not used. +- **Both floor forms** (Configuration and the customer page) gain `min_agent`. It is **required above + the vouched golden, server-side**: a missing value redirects with `floor_needs_min_agent`, an + unparseable one with `floor_min_agent_invalid`, and nothing is stored. The customer page also + renders `floor_set` / `floor_invalid`, which it silently dropped before. +- **Visibility.** The Hosts page marks a box served by declaration `floor: declared MinAgent`. The API + logs `[INFO] managed floor SERVED for : floor , agent requirement "" from ` + once per change (the HELD log is unchanged). +- **Not touched:** the vouch path (`handleSetArtifacts`) and the R-120 gate. + +**TESTS.** `internal/store/declared_floor_test.go` — scenarios A (served), B (held, original text), +C (undeclared above golden held exactly as before), D (inside the golden the manifest governs), a +declaration not carrying to a different floor, and the per-customer declaration. `internal/web/ +declared_floor_test.go` — scenario E through both real handlers (refused, nothing stored), each branch +of the Hosts badge, and the customer page's `min_agent` input. **Red-proofs:** removing the declared +branch fails A; serving an undeclared floor fails C (`documentation/audits/rulings-r472-r475-2026-09-13/`). + +**A trap met on the way.** The first cut added `min_agent` to the customer template but not to that +page's data struct. `html/template` stopped rendering mid-page, and five existing customer-page tests +failed on missing sections, not on the field. The render test above now pins the input. + +**Deploy:** `manifests/hub.yaml` → `felhom-hub:0.112.0`, ArgoCD sync. Runbooks: `publish-train-rules.md` +rule 1, `RUNBOOK-manual-build.md` §3 "Raise the floor to a release with no golden". + ## v0.111.1 — the alarm stops promising a rescue that does not exist (2026-09-01, R-434) **Text-only patch on a live alarm. No controller change, no golden owed, no floor change.** diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index b2ceb5cb..f99a5908 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "strings" + "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/assets" @@ -58,6 +59,10 @@ type Handler struct { assetsMgr *assets.Manager latestVersion LatestVersionProvider + // floorNotes (R-472): customer → the last served-floor decision logged, so the SERVED line is written + // once per change. Zero value is ready to use. + floorNotes sync.Map + // App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503. mailSender mailrelay.Sender mailLimiter *mailRateLimiter @@ -592,7 +597,20 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { // on the dashboard, never silently stale. if fd := h.store.ResolveManagedFloor(payload.CustomerID); fd.Floor != "" { resp["min_controller_version"] = fd.Floor + // R-472: say where the served floor's agent requirement came from — once per change, not per + // report (a box reports every few minutes, and a line repeated that often is a line nobody reads). + note := fd.Floor + "|" + fd.MinAgentSource + "|" + fd.MinAgent + if prev, ok := h.floorNotes.Load(payload.CustomerID); !ok || prev.(string) != note { + h.floorNotes.Store(payload.CustomerID, note) + src := fd.MinAgentSource + if src == "" { + src = "none (uncoupled release)" + } + h.logger.Printf("[INFO] managed floor SERVED for %s: floor %s, agent requirement %q from %s (golden %s)", + payload.CustomerID, fd.Floor, fd.MinAgent, src, fd.GoldenVersion) + } } else if fd.Held { + h.floorNotes.Delete(payload.CustomerID) // ONE sentence, from the decision itself — the two hold reasons must never drift apart // across this line and the dashboard (see ManagedFloorDecision.HoldReason). h.logger.Printf("[INFO] managed floor HELD for %s: %s (controller floor withheld)", @@ -2021,8 +2039,8 @@ var allowedEventTypes = map[string]bool{ "offsite_proof_empty": true, // R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd. "offsite_snapshots_dropped": true, - "crossdrive_completed": true, - "crossdrive_failed": true, + "crossdrive_completed": true, + "crossdrive_failed": true, // controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's // dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would // discard the numbers (templates.go:129 priority)). diff --git a/hub/internal/store/declared_floor_test.go b/hub/internal/store/declared_floor_test.go new file mode 100644 index 00000000..85b3b5d6 --- /dev/null +++ b/hub/internal/store/declared_floor_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "strings" + "testing" +) + +// R-472 (hub v0.112.0) — a floor above the vouched golden carries its own declared MinAgent. +// +// The golden is 0.236.0 with manifest MinAgent 0.129.0 in every case — the live numbers of +// 2026-09-13 — so the tests exercise the exact shape that was HELD in production. + +func declaredBase(t *testing.T, agent string) *Store { + t.Helper() + s := newTestStore(t) + if err := s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.236.0", MinAgent: "0.129.0"}); err != nil { + t.Fatal(err) + } + setHostAgent(t, s, "c1", "h1", agent) + return s +} + +// SCENARIO A — above the golden, MinAgent declared, agent new enough → SERVED, source "declared". +// +// COMPANION RED-PROOF A (REPORT.md): delete the `beyondGolden && d.DeclaredMinAgent != ""` branch +// from ResolveManagedFloor. The floor is then held beyond the golden and this test fails. +func TestDeclaredMinAgent_A_AboveGoldenServedWhenAgentMeetsIt(t *testing.T) { + s := declaredBase(t, "0.129.0") + _ = s.SetGlobalMinControllerVersion("0.239.0") + _ = s.SetGlobalFloorDeclaredMinAgent("0.239.0", "0.129.0") + fd := s.ResolveManagedFloor("c1") + if fd.Held || fd.Floor != "0.239.0" { + t.Fatalf("a declared floor above the golden must be SERVED: %+v (reason %q)", fd, fd.HoldReason()) + } + if fd.MinAgentSource != MinAgentSourceDeclared || fd.MinAgent != "0.129.0" { + t.Errorf("the decision must say the requirement was declared: %+v", fd) + } +} + +// SCENARIO B — declared, but the agent is too old → HELD with the ORIGINAL agent sentence. +func TestDeclaredMinAgent_B_AgentBelowDeclaredIsHeldWithTheOriginalText(t *testing.T) { + s := declaredBase(t, "0.128.0") + _ = s.SetGlobalMinControllerVersion("0.239.0") + _ = s.SetGlobalFloorDeclaredMinAgent("0.239.0", "0.129.0") + fd := s.ResolveManagedFloor("c1") + if !fd.Held || fd.Floor != "" || fd.HeldBeyondGolden { + t.Fatalf("an agent below the declared MinAgent must be HELD for the agent reason: %+v", fd) + } + if want := "held: agent 0.128.0 < MinAgent 0.129.0"; fd.HoldReason() != want { + t.Errorf("hold text = %q, want %q", fd.HoldReason(), want) + } +} + +// SCENARIO C — the NEGATIVE CONTROL: above the golden with NO declaration → held beyond golden, +// exactly today's text. +// +// COMPANION RED-PROOF C (REPORT.md): make an undeclared floor above the golden fall through to the +// agent comparison. It is then SERVED and this test fails. +func TestDeclaredMinAgent_C_UndeclaredAboveGoldenIsHeldExactlyAsBefore(t *testing.T) { + s := declaredBase(t, "0.130.0") + _ = s.SetGlobalMinControllerVersion("0.239.0") + fd := s.ResolveManagedFloor("c1") + if !fd.Held || !fd.HeldBeyondGolden || fd.Floor != "" { + t.Fatalf("an UNDECLARED floor above the golden must stay held beyond the golden: %+v", fd) + } + if !strings.Contains(fd.HoldReason(), "is ABOVE the vouched golden 0.236.0, so its agent requirement is unknown") { + t.Errorf("the hold text must be unchanged: %q", fd.HoldReason()) + } +} + +// SCENARIO D — at/below the golden with a declaration anyway → the MANIFEST governs, the +// declaration is recorded and not used. +func TestDeclaredMinAgent_D_InsideTheGoldenTheManifestGoverns(t *testing.T) { + s := declaredBase(t, "0.129.5") + _ = s.SetGlobalMinControllerVersion("0.236.0") + _ = s.SetGlobalFloorDeclaredMinAgent("0.236.0", "0.130.0") // stricter than the manifest, on purpose + fd := s.ResolveManagedFloor("c1") + if fd.Held || fd.Floor != "0.236.0" { + t.Fatalf("inside the golden the manifest's 0.129.0 governs, so agent 0.129.5 is served: %+v", fd) + } + if fd.MinAgentSource != MinAgentSourceManifest || fd.MinAgent != "0.129.0" || fd.DeclaredMinAgent != "0.130.0" { + t.Errorf("the declaration must be recorded but not used inside the golden: %+v", fd) + } +} + +// A declaration describes ONE release. Raising the floor without re-declaring must not inherit it. +func TestDeclaredMinAgent_DoesNotCarryToADifferentFloor(t *testing.T) { + s := declaredBase(t, "0.130.0") + _ = s.SetGlobalMinControllerVersion("0.239.0") + _ = s.SetGlobalFloorDeclaredMinAgent("0.239.0", "0.129.0") + _ = s.SetGlobalMinControllerVersion("0.240.0") // raised by another path, no new declaration + if got := s.GlobalFloorDeclaredMinAgent(); got != "" { + t.Fatalf("a declaration made for 0.239.0 must not apply to 0.240.0, got %q", got) + } + if fd := s.ResolveManagedFloor("c1"); !fd.HeldBeyondGolden { + t.Errorf("the undeclared 0.240.0 must be held beyond the golden: %+v", fd) + } +} + +// The per-customer override's OWN declaration wins where the override wins — never the global one. +func TestDeclaredMinAgent_PerCustomerDeclarationWinsWithItsOverride(t *testing.T) { + s := declaredBase(t, "0.129.0") + if err := s.SaveCustomerConfig(&CustomerConfig{CustomerID: "c1", RetrievalPassword: "x", APIKey: "y"}); err != nil { + t.Fatal(err) + } + _ = s.SetGlobalMinControllerVersion("0.239.0") + _ = s.SetGlobalFloorDeclaredMinAgent("0.239.0", "0.129.0") + _ = s.SetMinControllerVersion("c1", "0.241.0") // override, undeclared + if fd := s.ResolveManagedFloor("c1"); !fd.HeldBeyondGolden { + t.Fatalf("an undeclared OVERRIDE must not borrow the global floor's declaration: %+v", fd) + } + _ = s.SetCustomerFloorDeclaredMinAgent("c1", "0.241.0", "0.129.0") + if fd := s.ResolveManagedFloor("c1"); fd.Held || fd.Floor != "0.241.0" || fd.MinAgentSource != MinAgentSourceDeclared { + t.Fatalf("the override's own declaration must serve it: %+v", fd) + } +} diff --git a/hub/internal/store/floor_declared.go b/hub/internal/store/floor_declared.go new file mode 100644 index 00000000..ecbc6898 --- /dev/null +++ b/hub/internal/store/floor_declared.go @@ -0,0 +1,81 @@ +package store + +import ( + "strings" + + "gitea.dooplex.hu/admin/felhom-hub/internal/semver" +) + +// ── The MinAgent a floor DECLARES (hub v0.112.0, R-472) ─────────────────────────────────────────── +// +// Publish-train rule 1 said "the manifest leads the floor": a floor above the vouched golden was HELD, +// because the only record of a controller's agent requirement was the golden manifest's MinAgent, and +// that value describes the golden, not the release being served (R-216). Operator ruling 2026-09-13 +// (option B): a floor may carry its own requirement, read from the release's CHANGELOG header +// (`**MinAgent: X.Y.Z**`, enforced by the controller's gate). With it, the same agent comparison applies +// above the golden; without it, the hold is exactly as before. +// +// STORED AS "FLOOR=MINAGENT", ON PURPOSE. A declaration describes ONE release. Storing the MinAgent +// alone would let a later floor raise — made without re-declaring — silently inherit the old +// requirement, which is a stale fact wearing a current label. So a declaration counts only while the +// floor it was made for is the floor in force; any other floor reads as undeclared and is held above +// the golden. Pinned by TestDeclaredMinAgent_DoesNotCarryToADifferentFloor. + +const settingGlobalFloorDeclaredMinAgent = "min_controller_version_declared_min_agent" + +func encodeDeclaredMinAgent(floor, minAgent string) string { + if floor == "" || minAgent == "" { + return "" + } + return floor + "=" + minAgent +} + +// decodeDeclaredMinAgent returns the declared MinAgent only if it was declared for `floor` and parses. +func decodeDeclaredMinAgent(stored, floor string) string { + parts := strings.SplitN(stored, "=", 2) + if len(parts) != 2 || floor == "" || parts[0] != floor || !semver.Valid(parts[1]) { + return "" + } + return parts[1] +} + +// SetGlobalFloorDeclaredMinAgent records the MinAgent declared with the global floor (empty clears). +func (s *Store) SetGlobalFloorDeclaredMinAgent(floor, minAgent string) error { + return s.setSetting(settingGlobalFloorDeclaredMinAgent, encodeDeclaredMinAgent(floor, minAgent)) +} + +// GlobalFloorDeclaredMinAgent returns the MinAgent declared for the CURRENT global floor, or "". +func (s *Store) GlobalFloorDeclaredMinAgent() string { + return decodeDeclaredMinAgent(s.getSetting(settingGlobalFloorDeclaredMinAgent), s.GetGlobalMinControllerVersion()) +} + +// SetCustomerFloorDeclaredMinAgent records the MinAgent declared with a per-customer floor. +func (s *Store) SetCustomerFloorDeclaredMinAgent(customerID, floor, minAgent string) error { + _, err := s.db.Exec(` + UPDATE customer_configs SET min_controller_declared_min_agent = ?, updated_at = datetime('now') + WHERE customer_id = ?`, + encodeDeclaredMinAgent(floor, minAgent), customerID, + ) + return err +} + +// CustomerFloorDeclaredMinAgent returns the MinAgent declared for the customer's CURRENT override, or "". +func (s *Store) CustomerFloorDeclaredMinAgent(customerID string) string { + var stored, floor string + if err := s.db.QueryRow(`SELECT min_controller_declared_min_agent, min_controller_version FROM customer_configs WHERE customer_id = ?`, + customerID).Scan(&stored, &floor); err != nil { + return "" + } + return decodeDeclaredMinAgent(stored, floor) +} + +// DeclaredFloorMinAgent returns the MinAgent declared with the floor that WINS for this customer — +// the per-customer override's own declaration when an override is in force, else the global floor's. +// The same precedence as EffectiveMinControllerVersion, so a declaration never attaches to a floor it +// was not made for. +func (s *Store) DeclaredFloorMinAgent(customerID string) string { + if cfg, err := s.GetCustomerConfig(customerID); err == nil && cfg != nil && cfg.MinControllerVersion != "" { + return s.CustomerFloorDeclaredMinAgent(customerID) + } + return s.GlobalFloorDeclaredMinAgent() +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 4ab09761..ef49ec6a 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -171,6 +171,10 @@ func (s *Store) migrate() error { // config). Idempotent. s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_version TEXT NOT NULL DEFAULT ''") + // v0.112.0 (R-472): the MinAgent DECLARED with a per-customer floor, stored as "FLOOR=MINAGENT" so + // it only ever applies to the exact floor it was declared for (see floor_declared.go). Idempotent. + s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_declared_min_agent TEXT NOT NULL DEFAULT ''") + // v0.26.0: per-customer config_version — a monotonic counter bumped on every config save. The // report ACK advertises it; the controller compares it against its last-applied version and, on a // change, re-pulls controller.yaml + self-restarts (pull-based config delivery — no inbound). It is @@ -1985,8 +1989,21 @@ type ManagedFloorDecision struct { // ABOVE the vouched golden, so the manifest's MinAgent does not describe the version being served // and the hub does not know its agent requirement (R-216). HeldBeyondGolden bool + // DeclaredMinAgent is the agent requirement the operator DECLARED with this floor (read from the + // release's own CHANGELOG header), "" when none was declared for this exact floor. R-472. + DeclaredMinAgent string + // MinAgentSource says where MinAgent came from: MinAgentSourceManifest (the vouched golden's) or + // MinAgentSourceDeclared (the floor's own declaration, used ONLY above the golden). "" = no + // requirement at all (an uncoupled release). + MinAgentSource string } +// Where a managed floor's agent requirement came from. See ManagedFloorDecision.MinAgentSource. +const ( + MinAgentSourceManifest = "manifest" + MinAgentSourceDeclared = "declared" +) + // HoldReason is the ONE operator-facing sentence for a held floor, so the two hold reasons can never // drift apart across the log line and the dashboard again. // @@ -2073,15 +2090,30 @@ func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision { } man := s.GetArtifactManifest() d.MinAgent, d.GoldenVersion = man.MinAgent, man.GoldenVersion + if d.MinAgent != "" { + d.MinAgentSource = MinAgentSourceManifest + } + // R-472 (operator ruling 2026-09-13, option B): a floor may carry its OWN declared MinAgent, read + // from the release's CHANGELOG header. Above the golden that declaration REPLACES the manifest's — + // the manifest describes the golden, not this release (R-216), and the declaration is the one fact + // that was missing. At or below the golden the manifest still governs (publish-train rule 1 inside + // the golden, unchanged): the declaration is recorded for display and deliberately NOT used. + d.DeclaredMinAgent = s.DeclaredFloorMinAgent(customerID) + beyondGolden := semver.Valid(d.Floor) && semver.Valid(d.GoldenVersion) && semver.Compare(d.Floor, d.GoldenVersion) > 0 + if beyondGolden && d.DeclaredMinAgent != "" { + d.MinAgent, d.MinAgentSource = d.DeclaredMinAgent, MinAgentSourceDeclared + } if d.MinAgent == "" { return d // uncoupled release — no agent gate (Scenario C: unchanged from before) } if h, err := s.GetHostByCustomer(customerID); err == nil && h != nil { d.AgentVersion = h.AgentVersion } - // R-216: the floor names a controller the manifest does not describe → the agent requirement is - // UNKNOWN, so hold. Only when both versions parse — an unreadable golden must not gate the fleet. - if semver.Valid(d.Floor) && semver.Valid(d.GoldenVersion) && semver.Compare(d.Floor, d.GoldenVersion) > 0 { + // R-216: the floor names a controller the manifest does not describe and no requirement was + // DECLARED for it → the agent requirement is UNKNOWN, so hold. Only when both versions parse — an + // unreadable golden must not gate the fleet. A declared requirement skips this and meets the SAME + // agent comparison below: nothing about the comparison is weakened, only its input's source. + if beyondGolden && d.MinAgentSource != MinAgentSourceDeclared { d.Held, d.HeldBeyondGolden = true, true d.Floor = "" return d diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 3b7f1632..7b1d4294 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -15,6 +15,7 @@ import ( cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" + "gitea.dooplex.hu/admin/felhom-hub/internal/semver" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) @@ -63,7 +64,9 @@ type customerListEntry struct { // Phase 2 managed-update floor FloorOverride string // per-customer override ("" = none) EffectiveFloor string // override else global ("" = no floor) - BelowFloor bool // current < effective floor (would auto-update) + // FloorDeclaredMinAgent (R-472) — the MinAgent declared with the per-customer override, "" = none. + FloorDeclaredMinAgent string + BelowFloor bool // current < effective floor (would auto-update) } // handleConfigList shows all customers (merged from configs + reports). @@ -134,6 +137,7 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { // Phase 2 floor: resolve each customer's effective floor (override else global) + below-floor flag. globalFloor := s.store.GetGlobalMinControllerVersion() for _, e := range merged { + e.FloorDeclaredMinAgent = s.store.CustomerFloorDeclaredMinAgent(e.CustomerID) e.EffectiveFloor = e.FloorOverride if e.EffectiveFloor == "" { e.EffectiveFloor = globalFloor @@ -291,9 +295,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI). Deletable bool - HasReports bool - Customer *store.CustomerSummary - Report map[string]interface{} + HasReports bool + Customer *store.CustomerSummary + Report map[string]interface{} // BackupCard (R-331) is resolved in Go, not in the template: the card must distinguish // "no data reported" from "measured empty" from "never measured", and a {{if}} chain over // Report's float64s cannot. See backup_card.go. @@ -306,10 +310,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c ControllerURL string // Phase 2 managed-update floor controls - FloorOverride string // per-customer override ("" = none) - GlobalFloor string // global default (hub_settings → config/env) - EffectiveFloor string // override else global ("" = no floor) - BelowFloor bool // current < effective floor (would auto-update) + FloorOverride string // per-customer override ("" = none) + // FloorDeclaredMinAgent (R-472) — the MinAgent declared with the override, "" = none. + FloorDeclaredMinAgent string + GlobalFloor string // global default (hub_settings → config/env) + EffectiveFloor string // override else global ("" = no floor) + BelowFloor bool // current < effective floor (would auto-update) NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry @@ -468,10 +474,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c UpdateAvailable: updateAvailable, ControllerURL: controllerURL, - FloorOverride: floorOverride, - GlobalFloor: globalFloor, - EffectiveFloor: effectiveFloor, - BelowFloor: belowFloor, + FloorOverride: floorOverride, + FloorDeclaredMinAgent: s.store.CustomerFloorDeclaredMinAgent(customerID), + GlobalFloor: globalFloor, + EffectiveFloor: effectiveFloor, + BelowFloor: belowFloor, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, @@ -1080,12 +1087,22 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther) return } + ma, flash := s.floorDeclaredMinAgent(r, v) + if flash != "" { + http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther) + return + } if err := s.store.SetGlobalMinControllerVersion(v); err != nil { s.logger.Printf("[ERROR] Failed to set global floor: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } - s.logger.Printf("[INFO] Global controller-version floor set to %q", v) + if err := s.store.SetGlobalFloorDeclaredMinAgent(v, ma); err != nil { + s.logger.Printf("[ERROR] Failed to record the global floor's declared MinAgent: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] Global controller-version floor set to %q (declared MinAgent %q)", v, ma) // Direction-2: the global floor affects every config-managed customer — wake each long-polling // box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances). if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil { @@ -1096,6 +1113,29 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther) } +// floorDeclaredMinAgent reads and validates the `min_agent` a floor form declares (R-472). It returns +// the normalised value, or a flash key when the form must be REFUSED with nothing stored: +// +// - floor_min_agent_invalid — something was typed and it is not X.Y.Z; +// - floor_needs_min_agent — the floor is ABOVE the vouched golden and no MinAgent was declared. +// Such a floor would only be HELD on every box (publish-train rule 1); refusing it at the form +// turns a silent fleet-wide hold into a sentence the operator reads at the moment of saving. +// +// At or below the golden a declaration is optional and, if given, recorded but not used (the +// manifest governs there — see store.ResolveManagedFloor). +func (s *Server) floorDeclaredMinAgent(r *http.Request, floor string) (string, string) { + ma, ok := normalizeFloorInput(r.FormValue("min_agent")) + if !ok { + return "", "floor_min_agent_invalid" + } + golden := s.store.GetArtifactManifest().GoldenVersion + if floor != "" && ma == "" && semver.Valid(floor) && semver.Valid(golden) && semver.Compare(floor, golden) > 0 { + s.logger.Printf("[WARN] floor %s refused: it is above the vouched golden %s and no MinAgent was declared (R-472)", floor, golden) + return "", "floor_needs_min_agent" + } + return ma, "" +} + // validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact // handler (clears that artifact's checksum). var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`) @@ -1319,12 +1359,22 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther) return } + ma, flash := s.floorDeclaredMinAgent(r, v) + if flash != "" { + http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther) + return + } if err := s.store.SetMinControllerVersion(customerID, v); err != nil { s.logger.Printf("[ERROR] Failed to set floor for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } - s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q", customerID, v) + if err := s.store.SetCustomerFloorDeclaredMinAgent(customerID, v, ma); err != nil { + s.logger.Printf("[ERROR] Failed to record the declared MinAgent for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q (declared MinAgent %q)", customerID, v, ma) s.bumpIntent(customerID) // Direction-2: deliver the new floor to the box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther) } diff --git a/hub/internal/web/declared_floor_test.go b/hub/internal/web/declared_floor_test.go new file mode 100644 index 00000000..2309296a --- /dev/null +++ b/hub/internal/web/declared_floor_test.go @@ -0,0 +1,148 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-472 SCENARIO E — the floor forms, through the real handlers. + +func postGlobalFloor(t *testing.T, s *Server, form url.Values) string { + t.Helper() + r := httptest.NewRequest(http.MethodPost, "/configuration/global-floor", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleSetGlobalFloor(w, r) + return w.Header().Get("Location") +} + +func TestGlobalFloorForm_RefusesAboveGoldenWithoutMinAgent(t *testing.T) { + s, st := newTestServer(t) + _ = st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.236.0", MinAgent: "0.129.0"}) + _ = st.SetGlobalMinControllerVersion("0.236.0") + + loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.239.0"}}) + if !strings.Contains(loc, "flash=floor_needs_min_agent") { + t.Fatalf("a floor above the golden without min_agent must be refused, got %q", loc) + } + if got := st.GetGlobalMinControllerVersion(); got != "0.236.0" { + t.Errorf("a refused form must store NOTHING — floor is now %q", got) + } + + loc = postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.239.0"}, "min_agent": {"0.12x"}}) + if !strings.Contains(loc, "flash=floor_min_agent_invalid") || st.GetGlobalMinControllerVersion() != "0.236.0" { + t.Fatalf("an unparseable min_agent must be refused with nothing stored, got %q", loc) + } + + loc = postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.239.0"}, "min_agent": {"0.129.0"}}) + if !strings.Contains(loc, "flash=floor_set") { + t.Fatalf("a declared floor must be saved, got %q", loc) + } + if st.GetGlobalMinControllerVersion() != "0.239.0" || st.GlobalFloorDeclaredMinAgent() != "0.129.0" { + t.Errorf("floor=%q declared=%q", st.GetGlobalMinControllerVersion(), st.GlobalFloorDeclaredMinAgent()) + } + // Inside the golden no declaration is needed — today's behaviour is unchanged. + if loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.230.0"}}); !strings.Contains(loc, "flash=floor_set") { + t.Errorf("a floor at/below the golden must save without min_agent, got %q", loc) + } +} + +func TestCustomerFloorForm_RefusesAboveGoldenWithoutMinAgent(t *testing.T) { + s, st := newTestServer(t) + _ = st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.236.0", MinAgent: "0.129.0"}) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", RetrievalPassword: "x", APIKey: "y"}); err != nil { + t.Fatal(err) + } + post := func(form url.Values) string { + r := httptest.NewRequest(http.MethodPost, "/customers/c1/floor", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleSetCustomerFloor(w, r, "c1") + return w.Header().Get("Location") + } + if loc := post(url.Values{"min_controller_version": {"0.239.0"}}); !strings.Contains(loc, "flash=floor_needs_min_agent") { + t.Fatalf("got %q", loc) + } + if cfg, _ := st.GetCustomerConfig("c1"); cfg.MinControllerVersion != "" { + t.Errorf("a refused override must store nothing, got %q", cfg.MinControllerVersion) + } + if loc := post(url.Values{"min_controller_version": {"0.239.0"}, "min_agent": {"0.129.0"}}); !strings.Contains(loc, "flash=floor_set") { + t.Fatalf("got %q", loc) + } + if got := st.CustomerFloorDeclaredMinAgent("c1"); got != "0.129.0" { + t.Errorf("declared = %q", got) + } +} + +// R-472 — the Hosts badge is a template gate, so each branch gets a render test (hub rules: seam-wiring +// covers template gates). Served-by-declaration shows the badge; undeclared above the golden shows +// "floor held" and NOT the badge; inside the golden (manifest governs) shows neither. +func renderHostsForFloor(t *testing.T, floor, declared string) string { + t.Helper() + s, st := newTestServer(t) + _ = st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.236.0", MinAgent: "0.129.0"}) + if err := st.UpsertHost(&store.Host{HostID: "demo-hp-01", CustomerID: "c1", APIKey: "k1"}); err != nil { + t.Fatal(err) + } + if err := st.SaveHostReport("demo-hp-01", "c1", []byte(testReportJSON), store.HostReportDenorm{AgentVersion: "0.130.0"}); err != nil { + t.Fatal(err) + } + if err := st.SetGlobalMinControllerVersion(floor); err != nil { + t.Fatal(err) + } + if err := st.SetGlobalFloorDeclaredMinAgent(floor, declared); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.handleHostsList(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + return rr.Body.String() +} + +func TestHostsBadge_EachFloorBranchRenders(t *testing.T) { + const badge, held = "floor: declared MinAgent", "floor held" + if body := renderHostsForFloor(t, "0.239.0", "0.129.0"); !strings.Contains(body, badge) || strings.Contains(body, held) { + t.Errorf("declared floor above the golden: want the badge and no hold (badge=%v held=%v)", + strings.Contains(body, badge), strings.Contains(body, held)) + } + if body := renderHostsForFloor(t, "0.239.0", ""); strings.Contains(body, badge) || !strings.Contains(body, held) { + t.Errorf("undeclared floor above the golden: want the hold and no badge (badge=%v held=%v)", + strings.Contains(body, badge), strings.Contains(body, held)) + } + if body := renderHostsForFloor(t, "0.236.0", "0.129.0"); strings.Contains(body, badge) || strings.Contains(body, held) { + t.Errorf("floor at the golden: the manifest governs, want neither (badge=%v held=%v)", + strings.Contains(body, badge), strings.Contains(body, held)) + } +} + +// The customer page renders the declared value back into its min_agent input — the form is reachable +// and round-trips what was stored. (Its absence once truncated the whole page render.) +func TestCustomerPage_RendersDeclaredMinAgentInput(t *testing.T) { + s, st := newTestServer(t) + _ = st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.236.0", MinAgent: "0.129.0"}) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil { + t.Fatal(err) + } + // The Controller Update card (and its floor form) renders only for a reporting customer. + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + if err := st.SetMinControllerVersion("acme", "0.239.0"); err != nil { + t.Fatal(err) + } + if err := st.SetCustomerFloorDeclaredMinAgent("acme", "0.239.0", "0.129.0"); err != nil { + t.Fatal(err) + } + html := renderCustomerPage(t, s, "acme") + if !strings.Contains(html, `name="min_agent" value="0.129.0"`) { + t.Error("the per-customer floor form must render min_agent with the declared value") + } +} diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 4baa84ed..596302c2 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -518,6 +518,9 @@ type hostListRow struct { // agent is below the current golden's MinAgent. HeldReason carries the operator-facing text. FloorHeld bool HeldReason string + // FloorSource (R-472) — where a SERVED floor's agent requirement came from: "manifest" or + // "declared" ("" = no floor, or an uncoupled release). + FloorSource string } // customerName resolves a display name for a customer id (config first, then the last @@ -570,6 +573,8 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) { if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held { row.FloorHeld = true row.HeldReason = fd.HoldReason() + } else if fd.Floor != "" { + row.FloorSource = fd.MinAgentSource } // Guest counts from the reality table (per-host accurate). diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 247417dd..d36d2fc4 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -976,10 +976,13 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) { "AssetLastSync": assetLastSync, "GlobalFloor": s.store.GetGlobalMinControllerVersion(), "FloorRes": s.store.ResolveGlobalFloor(), - "Artifacts": s.store.GetArtifactManifest(), - "AgentChoices": agentChoices, - "GoldenChoices": goldenChoices, - "Flash": r.URL.Query().Get("flash"), + // R-472: the MinAgent declared with the global floor, and the golden it is compared against. + "FloorDeclaredMinAgent": s.store.GlobalFloorDeclaredMinAgent(), + "FloorGoldenVersion": s.store.GetArtifactManifest().GoldenVersion, + "Artifacts": s.store.GetArtifactManifest(), + "AgentChoices": agentChoices, + "GoldenChoices": goldenChoices, + "Flash": r.URL.Query().Get("flash"), } if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil { s.logger.Printf("[ERROR] configuration.html template: %v", err) diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index 52302e20..f726e033 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -38,6 +38,12 @@ {{if eq .Flash "floor_invalid"}}
Invalid version — use X.Y.Z (or blank to clear).
{{end}} + {{if eq .Flash "floor_needs_min_agent"}} +
This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.
+ {{end}} + {{if eq .Flash "floor_min_agent_invalid"}} +
Invalid MinAgent — use X.Y.Z. Nothing was saved.
+ {{end}} {{if eq .Flash "artifacts_set"}}
Artifact manifest saved.
{{end}} @@ -109,6 +115,7 @@
{{.CSRFField}} + DB override: {{if .FloorRes.DBValue}}v{{.FloorRes.DBValue}}{{else}}none{{end}}
diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index e4114b6b..fe7bc977 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -49,6 +49,10 @@ {{else if eq .Flash "password_regenerated"}}Retrieval password regenerated. {{else if eq .Flash "offsite_reissued"}}Offsite credentials re-issued — a fresh one-time password is staged; the controller picks it up on its next config refresh. {{else if eq .Flash "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's pvesm status must show the PBS entry active. A converged agent can report applied while the storage still authenticates 401 (R-39). + {{else if eq .Flash "floor_set"}}Controller-version floor saved. + {{else if eq .Flash "floor_invalid"}}Invalid version — use X.Y.Z (or blank to clear). + {{else if eq .Flash "floor_needs_min_agent"}}This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved. + {{else if eq .Flash "floor_min_agent_invalid"}}Invalid MinAgent — use X.Y.Z. Nothing was saved. {{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen. {{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored. {{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard. @@ -683,6 +687,7 @@ {{.CSRFField}} + Boxes below the effective floor auto-update on their next report. Blank clears the override. diff --git a/hub/internal/web/templates/hosts.html b/hub/internal/web/templates/hosts.html index d713724d..b8c9075f 100644 --- a/hub/internal/web/templates/hosts.html +++ b/hub/internal/web/templates/hosts.html @@ -99,7 +99,7 @@ {{.HostID}} {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} - {{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}}{{if .FloorHeld}} floor held{{end}} + {{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}}{{if .FloorHeld}} floor held{{else if eq .FloorSource "declared"}} floor: declared MinAgent{{end}} {{.StatusLabel}} {{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}} {{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}