hub v0.112.0: a floor carries a declared MinAgent past the golden (R-472)
gates / gates (push) Successful in 18s

Operator ruling 2026-09-13. Above the vouched golden, a floor saved with a
declared MinAgent is served under the same agent comparison; an undeclared
one is still held beyond the golden. The declaration is stored beside each
floor as FLOOR=MINAGENT so it never carries to a later floor. Both floor
forms require min_agent above the golden (flash floor_needs_min_agent,
nothing stored). The Hosts page and the API log name the source.

Vouch path and R-120 gate untouched. Scenarios A-E tested; red-proofs A
and C in documentation/audits/rulings-r472-r475-2026-09-13/.
This commit is contained in:
2026-09-13 16:47:11 +02:00
parent 5ef0f52bcd
commit f181efd6a7
17 changed files with 599 additions and 27 deletions
+20 -2
View File
@@ -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)).
+116
View File
@@ -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)
}
}
+81
View File
@@ -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()
}
+35 -3
View File
@@ -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
+64 -14
View File
@@ -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)
}
+148
View File
@@ -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")
}
}
+5
View File
@@ -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).
+7 -4
View File
@@ -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)
@@ -38,6 +38,12 @@
{{if eq .Flash "floor_invalid"}}
<div class="flash flash-error">Invalid version — use X.Y.Z (or blank to clear).</div>
{{end}}
{{if eq .Flash "floor_needs_min_agent"}}
<div class="flash flash-error">This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.</div>
{{end}}
{{if eq .Flash "floor_min_agent_invalid"}}
<div class="flash flash-error">Invalid MinAgent — use X.Y.Z. Nothing was saved.</div>
{{end}}
{{if eq .Flash "artifacts_set"}}
<div class="flash flash-success">Artifact manifest saved.</div>
{{end}}
@@ -109,6 +115,7 @@
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
<input type="text" name="min_agent" value="{{.FloorDeclaredMinAgent}}" placeholder="MinAgent from the release header" title="Required when the floor is above the vouched golden{{if .FloorGoldenVersion}} (v{{.FloorGoldenVersion}}){{end}} — read it from the release's CHANGELOG header (R-472)" style="padding: 0.3em 0.5em; width: 14em;">
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor…</button>
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
</form>
@@ -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 <code>pvesm status</code> must show the PBS entry <strong>active</strong>. A converged agent can report <code>applied</code> 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}}
<label style="font-size: 0.85em; color: #cbd5e1;">Per-customer override</label>
<input type="text" name="min_controller_version" value="{{.FloorOverride}}" placeholder="e.g. 0.87.0 (blank = use global)" style="padding: 0.3em 0.5em; font-size: 0.85em; width: 14em;">
<input type="text" name="min_agent" value="{{.FloorDeclaredMinAgent}}" placeholder="MinAgent (above the golden)" title="Required when the override is above the vouched golden — read it from the release's CHANGELOG header (R-472)" style="padding: 0.3em 0.5em; font-size: 0.85em; width: 12em;">
<button class="btn btn-outline btn-sm" type="submit">Save floor</button>
<span style="font-size: 0.8em; color: var(--text-2);">Boxes below the effective floor auto-update on their next report. Blank clears the override.</span>
</form>
+1 -1
View File
@@ -99,7 +99,7 @@
<tr onclick="window.location='/hosts/{{.HostID}}'" style="cursor: pointer;">
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</td>
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}{{if .FloorHeld}} <span class="status-badge status-warn" title="{{.HeldReason}}">floor held</span>{{end}}</td>
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}{{if .FloorHeld}} <span class="status-badge status-warn" title="{{.HeldReason}}">floor held</span>{{else if eq .FloorSource "declared"}} <span class="status-badge" title="served above the vouched golden: the agent requirement was declared with the floor (R-472)">floor: declared MinAgent</span>{{end}}</td>
<td><span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span></td>
<td>{{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}</td>
<td>{{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}</td>