hub v0.155.0: the floor can never deliver a withdrawn controller build (R-1085); RomM/Ghost evidence
gates / gates (push) Successful in 6m44s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 12:24:39 +02:00
1 parent 920c1aaf84
commit b17e9e993c
28 files changed
+955 -6

No files matched your search

+32 -1
View File
@@ -320,6 +320,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
GlobalFloor string // global default (hub_settings → config/env)
EffectiveFloor string // override else global ("" = no floor)
BelowFloor bool // current < effective floor (would auto-update)
// EffectiveFloorWithdrawn (R-1085) — why the effective floor is a withdrawn build, "" when it is not. The
// box is served no floor then (store.ResolveManagedFloor); the page says so instead of „will auto-update".
EffectiveFloorWithdrawn string
NotifPrefs *store.NotificationPrefs
RecentNotifications []store.NotificationLogEntry
@@ -500,6 +503,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
GlobalFloor: globalFloor,
EffectiveFloor: effectiveFloor,
BelowFloor: belowFloor,
EffectiveFloorWithdrawn: func() string {
r, _ := store.SpoiledControllerTag(effectiveFloor)
return r
}(),
NotifPrefs: notifPrefs,
RecentNotifications: recentNotifs,
@@ -1216,12 +1223,28 @@ func (s *Server) countBoxesBelowFloor(proposedGlobal string) int {
// (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0.
func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) {
v, ok := normalizeFloorInput(r.URL.Query().Get("v"))
_, spoiled := store.SpoiledControllerTag(v)
if spoiled {
ok = false // R-1085: a withdrawn build is not a floor the dialog may offer to confirm
}
count := 0
if ok && v != "" {
count = s.countBoxesBelowFloor(v)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count})
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "spoiled": spoiled, "below": count})
}
// refuseSpoiledFloor is the input half of R-1085, shared by the two floor forms (NOT normalizeFloorInput, which
// also validates agent and golden versions, whose numbering is their own): a withdrawn controller build is never
// saved as a floor. It logs the refusal and reports whether the caller must stop. The serve-time half is
// store.ResolveManagedFloor.
func (s *Server) refuseSpoiledFloor(v, where string) bool {
reason, spoiled := store.SpoiledControllerTag(v)
if spoiled {
s.logger.Printf("[WARN] %s floor %q REFUSED: a withdrawn controller build (%s) — nothing saved (R-1085)", where, v, reason)
}
return spoiled
}
// handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed
@@ -1236,6 +1259,10 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther)
return
}
if s.refuseSpoiledFloor(v, "global") {
http.Redirect(w, r, "/configuration?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
@@ -1598,6 +1625,10 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request,
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther)
return
}
if s.refuseSpoiledFloor(v, "customer "+customerID) {
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther)
+8 -3
View File
@@ -73,10 +73,10 @@ type Server struct {
// offsiteKeyAudit runs the daily off-site key check on demand (decision 69). nil → 503.
offsiteKeyAudit func(ctx context.Context) any
// offsiteWindowAdmin: operator one-shot grant / weekly switch (decision 68). nil → 503.
offsiteWindowGrant func(customerID string) error
offsiteWindowGrant func(customerID string) error
offsiteWindowGrantMax func(customerID string, maxRemove int) error
osUpdates OSUpdateAdmin
offsiteWindowSwitch func(on bool) error
offsiteWindowSwitch func(on bool) error
// operator key-file clean-up (decision 72) and abandonment cancel (decision 74). nil → 503.
offsiteRemoveUnpinned func(ctx context.Context, customerID string) (int, error)
offsiteAbandonCancel func(customerID, by string) (int, error)
@@ -139,7 +139,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
"timeAgo": timeAgo,
"timeAgo": timeAgo,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
return "—"
@@ -1234,6 +1234,11 @@ func (s *Server) renderConfiguration(w http.ResponseWriter, r *http.Request, cre
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"FloorRes": s.store.ResolveGlobalFloor(),
// R-1085: why the effective global floor is a withdrawn build ("" = it is not) — boxes are served no floor.
"FloorWithdrawn": func() string {
r, _ := store.SpoiledControllerTag(s.store.ResolveGlobalFloor().Effective)
return r
}(),
// 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,
+130
View File
@@ -0,0 +1,130 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1085, the input half — Scenario D: both floor forms refuse a withdrawn build (with or without the "v"), store
// NOTHING, and still save an ordinary version. Red-proof: remove a refuseSpoiledFloor call → its form saves the tag.
func TestGlobalFloorForm_RefusesAWithdrawnBuild(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetGlobalMinControllerVersion("0.314.0"); err != nil {
t.Fatal(err)
}
for _, v := range []string{"0.315.0", "v0.316.3"} {
loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {v}})
if !strings.Contains(loc, "flash=floor_spoiled") {
t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc)
}
if got := st.GetGlobalMinControllerVersion(); got != "0.314.0" {
t.Fatalf("%s: a refused form must store nothing — the floor is now %q", v, got)
}
}
if loc := postGlobalFloor(t, s, url.Values{"min_controller_version": {"0.317.0"}}); !strings.Contains(loc, "flash=floor_set") ||
st.GetGlobalMinControllerVersion() != "0.317.0" {
t.Fatalf("an ordinary version must still save, got %q / %q", loc, st.GetGlobalMinControllerVersion())
}
}
func TestCustomerFloorForm_RefusesAWithdrawnBuild(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", RetrievalPassword: "x", APIKey: "y"}); err != nil {
t.Fatal(err)
}
post := func(v string) string {
form := url.Values{"min_controller_version": {v}}
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")
}
for _, v := range []string{"v0.315.0", "0.316.3"} {
if loc := post(v); !strings.Contains(loc, "flash=floor_spoiled") {
t.Fatalf("%s: want flash=floor_spoiled, got %q", v, loc)
}
if cfg, _ := st.GetCustomerConfig("c1"); cfg.MinControllerVersion != "" {
t.Fatalf("%s: a refused override must store nothing, got %q", v, cfg.MinControllerVersion)
}
}
if loc := post("0.317.0"); !strings.Contains(loc, "flash=floor_set") {
t.Fatalf("an ordinary version must still save, got %q", loc)
}
}
// The confirm dialog's probe answers valid:false, spoiled:true — and counts nothing — for a withdrawn build.
func TestGlobalFloorImpact_WithdrawnBuildIsNotValid(t *testing.T) {
s, st := newTestServer(t)
seedReport(t, st, "old", "0.300.0")
probe := func(v string) (valid, spoiled bool, below int) {
w := httptest.NewRecorder()
s.handleGlobalFloorImpact(w, httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v="+v, nil))
var resp struct {
Valid bool `json:"valid"`
Spoiled bool `json:"spoiled"`
Below int `json:"below"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp.Valid, resp.Spoiled, resp.Below
}
if valid, spoiled, below := probe("0.316.3"); valid || !spoiled || below != 0 {
t.Errorf("withdrawn: want valid=false spoiled=true below=0, got %v %v %d", valid, spoiled, below)
}
if valid, spoiled, below := probe("0.317.0"); !valid || spoiled || below != 1 {
t.Errorf("ordinary: want valid=true spoiled=false below=1, got %v %v %d", valid, spoiled, below)
}
}
// Template gates, one render per branch (hub rules: seam-wiring covers template gates): the Configuration page and
// the customer page say „withdrawn" when the effective floor is a withdrawn build, and say nothing otherwise.
func TestConfigurationPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) {
for _, tc := range []struct {
floor string
want bool
}{{"0.316.3", true}, {"0.317.0", false}} {
s, st := newTestServer(t)
if err := st.SetGlobalMinControllerVersion(tc.floor); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleConfiguration(rr, httptest.NewRequest(http.MethodGet, "/configuration", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := strings.Contains(rr.Body.String(), `data-floor-withdrawn="1"`); got != tc.want {
t.Errorf("global floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want)
}
}
}
func TestCustomerPage_SaysWithdrawnOnlyForAWithdrawnFloor(t *testing.T) {
for _, tc := range []struct {
floor string
want bool
}{{"0.315.0", true}, {"0.317.0", false}} {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { // the floor card renders only for a reporting box
t.Fatal(err)
}
if err := st.SetMinControllerVersion("acme", tc.floor); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "acme")
if got := strings.Contains(html, `data-floor-withdrawn="1"`); got != tc.want {
t.Errorf("customer floor %s: withdrawn notice shown = %v, want %v", tc.floor, got, tc.want)
}
}
}
@@ -56,6 +56,9 @@
{{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_spoiled"}}
<div class="flash flash-error">This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved.</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}}
@@ -125,6 +128,7 @@
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
Effective floor:
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
{{if .FloorWithdrawn}}<span data-floor-withdrawn="1" style="color: #ef4444;">— this version is withdrawn ({{.FloorWithdrawn}}): every box is served no floor; set another version</span>{{end}}
{{if eq .FloorRes.Source "db"}}
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
{{else if eq .FloorRes.Source "env"}}
@@ -53,6 +53,7 @@
{{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_spoiled"}}This version is withdrawn (a faulty build) and cannot be set as the minimum. Nothing was saved.
{{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.
@@ -696,7 +697,8 @@
<span class="label">Effective floor (min. version)</span>
<span class="value">
{{if .EffectiveFloor}}v{{.EffectiveFloor}}
{{if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
{{if .EffectiveFloorWithdrawn}}<span data-floor-withdrawn="1" style="color: #ef4444; margin-left: 0.3em;">● this version is withdrawn ({{.EffectiveFloorWithdrawn}}) — the box is served no floor; set another version</span>
{{else if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
{{else}}<span style="color: var(--text-2); margin-left: 0.3em;">— at/above floor</span>{{end}}
{{else}}<span style="color: var(--text-2);">none (Phase 2 inert)</span>{{end}}
</span>