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

+17
View File
@@ -1,3 +1,20 @@
## v0.155.0 — the floor can never deliver a withdrawn controller build (R-1085) (2026-10-16)
`documentation/architecture/09-update-architecture.md` §0 (the [DESIGN] paragraph on withdrawn builds).
- One list: `internal/store/spoiled_tags.go` — `0.315.0` and `0.316.3` (R-1081), each with why it is withdrawn.
- **Input:** the global and per-customer floor forms refuse a listed tag (with or without `v`) with the new flash
`floor_spoiled`, store nothing, and log one `[WARN]`. The impact probe answers `valid:false, spoiled:true`.
A valid other version still saves.
- **Serve time:** `ResolveManagedFloor` holds a listed floor first (`HeldSpoiled`, its own `HoldReason`), whatever
stored it — an old value, a per-customer override, or the config/env default (also warned about at start).
The report ACK omits it and logs ONE `[WARN]` per change naming the tag. The Configuration and customer pages say
„this version is withdrawn"; the stored value is not rewritten.
- Tests: the store (every source held; an ordinary floor served), the forms, the probe, both branches of both page
notices, the real report handler (no floor in the ACK, one WARN across three reports), and the parity pin against
the `felhom-build-deploy` skill's two lines. Ten red-proofs, each seen failing
(`documentation/audits/romm-ghost-2026-10-16/B/hub-redproofs.txt`).
## v0.154.0 — the host page shows which physical disk each storage place sits on (2026-10-15)
Storage identity build A (`documentation/architecture/16-storage-physical-identity.md` §3; agent v0.157.0).
+6
View File
@@ -252,6 +252,12 @@ func main() {
// Phase 2 managed updates: seed the global controller-version floor fallback (config/env). A
// hub_settings row set via the operator UI overrides this at runtime.
dataStore.SetDefaultMinControllerVersion(cfg.ControllerUpdates.DefaultMinVersion)
if reason, spoiled := store.SpoiledControllerTag(cfg.ControllerUpdates.DefaultMinVersion); spoiled {
// R-1085: the config/env default is the one floor no form checks — ResolveManagedFloor holds it at serve
// time; say so at start, where whoever set it will look.
logger.Printf("[WARN] Default controller-version floor %s is a WITHDRAWN controller build (%s) — it is HELD for every box (R-1085)",
cfg.ControllerUpdates.DefaultMinVersion, reason)
}
if cfg.ControllerUpdates.DefaultMinVersion != "" {
logger.Printf("[INFO] Default controller-version floor: %s", cfg.ControllerUpdates.DefaultMinVersion)
}
+8
View File
@@ -677,6 +677,14 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
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.HeldSpoiled {
// R-1085: a withdrawn build — ONE [WARN] per change naming the tag and why, not one per report.
note := "spoiled|" + fd.RequestedFloor
if prev, ok := h.floorNotes.Load(payload.CustomerID); !ok || prev.(string) != note {
h.floorNotes.Store(payload.CustomerID, note)
h.logger.Printf("[WARN] managed floor HELD for %s: %s (controller floor withheld)",
payload.CustomerID, fd.HoldReason())
}
} else if fd.Held {
h.floorNotes.Delete(payload.CustomerID)
// ONE sentence, from the decision itself — the two hold reasons must never drift apart
@@ -0,0 +1,53 @@
package api
import (
"bytes"
"encoding/json"
"log"
"net/http"
"strings"
"testing"
)
// R-1085 over the wire — Scenario C through the PRODUCTION path (the real report handler → ResolveManagedFloor):
// a box is never told to install a withdrawn build, and the hub says why in ONE [WARN] naming the tag, not one per
// report. Red-proof: delete the SpoiledControllerTag block in store.ResolveManagedFloor → the ACK carries 0.316.3.
func TestReportACK_NeverServesAWithdrawnBuild(t *testing.T) {
h, st, _ := newTestHandler(t)
var logs bytes.Buffer
h.logger = log.New(&logs, "", 0)
ack := func() (interface{}, bool) {
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
if rr.Code != http.StatusOK {
t.Fatalf("report status = %d", rr.Code)
}
var m map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &m)
v, ok := m["min_controller_version"]
return v, ok
}
// A value stored the way an old hub (no form refusal) would have stored it.
if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if v, ok := ack(); ok {
t.Fatalf("report %d: the ACK must OMIT a withdrawn floor, got %v", i, v)
}
}
if n := strings.Count(logs.String(), "[WARN] managed floor HELD for c:"); n != 1 {
t.Errorf("want exactly ONE [WARN] across three reports, got %d:\n%s", n, logs.String())
}
if !strings.Contains(logs.String(), "0.316.3") || !strings.Contains(logs.String(), "WITHDRAWN") {
t.Errorf("the [WARN] must name the tag and say withdrawn:\n%s", logs.String())
}
// The control, on the same handler: an ordinary floor is served.
if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil {
t.Fatal(err)
}
if v, ok := ack(); !ok || v != "0.317.0" {
t.Fatalf("an ordinary floor must be served, got %v (present=%v)", v, ok)
}
}
+41
View File
@@ -0,0 +1,41 @@
package store
import (
"sort"
"strings"
)
// spoiledControllerTags are published felhom-controller tags whose image does NOT hold the code their number
// says (R-1081). They are never re-pushed and never deleted — a version number is pushed once — so the only
// safe thing left is that nothing ever SELECTS them (R-1085). The household's button and the daily auto-update
// take the highest tag and cannot reach them; the floor targets a version EXACTLY, so the floor is the path this
// list closes: both floor forms refuse a listed tag (web/configs.go), and ResolveManagedFloor HOLDS one that
// arrives any other way (an old stored value, the config/env default).
//
// THE SAME LIST is the marked „never deploy" line in `skills/felhom-build-deploy/SKILL.md`, for the hand deploy
// that names a tag. TestSpoiledControllerTags_MatchTheSkill fails when the two differ.
var spoiledControllerTags = map[string]string{
// 2026-10-15: running the build refusal against a real tag rebuilt and re-pushed 0.315.0 a fourth time, with
// v0.316.0's code under it (digest 8887343011d6 → b809201841f7).
"0.315.0": "re-pushed with v0.316.0's code under it",
// 2026-10-15: built from the SHARED working copy, which held 31 uncommitted files of another session's work.
"0.316.3": "built from a working copy holding another session's uncommitted files",
}
// SpoiledControllerTag reports whether v (a leading "v" and spaces allowed) is a withdrawn controller build, and
// why. A version that is not listed answers ("", false).
func SpoiledControllerTag(v string) (reason string, spoiled bool) {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
reason, spoiled = spoiledControllerTags[v]
return reason, spoiled
}
// SpoiledControllerTags lists the withdrawn controller builds, sorted — for the parity test and the logs.
func SpoiledControllerTags() []string {
out := make([]string, 0, len(spoiledControllerTags))
for v := range spoiledControllerTags {
out = append(out, v)
}
sort.Strings(out)
return out
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
)
// R-1085, the serve-time half: a withdrawn build is HELD however it reached the floor — a stored global value (as
// one written before the form refusal existed), a per-customer override, or the config/env default. Red-proof:
// delete the SpoiledControllerTag block in ResolveManagedFloor → every case below serves the tag.
func TestResolveManagedFloor_HoldsAWithdrawnBuildFromEverySource(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, st *Store)
tag string
}{
{"stored global value", func(t *testing.T, st *Store) {
if err := st.SetGlobalMinControllerVersion("0.316.3"); err != nil {
t.Fatal(err)
}
}, "0.316.3"},
{"per-customer override", func(t *testing.T, st *Store) {
if err := st.SetMinControllerVersion("c", "0.315.0"); err != nil {
t.Fatal(err)
}
}, "0.315.0"},
{"config/env default", func(t *testing.T, st *Store) { st.SetDefaultMinControllerVersion("0.316.3") }, "0.316.3"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := newTestStore(t)
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "k"}); err != nil {
t.Fatal(err)
}
tc.setup(t, st)
d := st.ResolveManagedFloor("c")
if d.Floor != "" || !d.Held || !d.HeldSpoiled {
t.Fatalf("a withdrawn build must be HELD and served as no floor, got %+v", d)
}
if d.RequestedFloor != tc.tag {
t.Errorf("RequestedFloor = %q, want %q (the message needs it)", d.RequestedFloor, tc.tag)
}
if r := d.HoldReason(); !strings.Contains(r, "WITHDRAWN") || !strings.Contains(r, tc.tag) {
t.Errorf("the hold reason must say withdrawn and name the tag, got %q", r)
}
if got := st.EffectiveMinControllerVersion("c"); got != tc.tag {
t.Errorf("the stored value must be left as it is (the page shows why), got %q", got)
}
})
}
}
// The control: a version that is not listed is served exactly as before (uncoupled, no manifest).
func TestResolveManagedFloor_AnUnlistedFloorIsServed(t *testing.T) {
st := newTestStore(t)
if err := st.SetGlobalMinControllerVersion("0.317.0"); err != nil {
t.Fatal(err)
}
if d := st.ResolveManagedFloor("c"); d.Floor != "0.317.0" || d.Held || d.HeldSpoiled {
t.Fatalf("an ordinary floor must be served, got %+v", d)
}
}
func TestSpoiledControllerTag_AcceptsTheFormsSpellings(t *testing.T) {
for _, v := range []string{"0.316.3", "v0.316.3", " 0.315.0 ", "v0.315.0"} {
if _, ok := SpoiledControllerTag(v); !ok {
t.Errorf("%q must be recognised as withdrawn", v)
}
}
for _, v := range []string{"", "0.317.0", "0.316.30", "0.316", "0.315.0-rc.1"} {
if _, ok := SpoiledControllerTag(v); ok {
t.Errorf("%q must NOT be withdrawn", v)
}
}
}
// THE PARITY PIN (R-1085 B3): the hub's list equals BOTH places the hand deploy reads it — the marked „never deploy"
// line and the step-3 pre-check in skills/felhom-build-deploy/SKILL.md. A missing skill file is a failure, never a
// skip. Red-proof: add "0.316.4" to spoiledControllerTags, or drop a tag from either skill line → this fails.
func TestSpoiledControllerTags_MatchTheSkill(t *testing.T) {
p := filepath.Join("..", "..", "..", "skills", "felhom-build-deploy", "SKILL.md")
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("the skill holds the hand deploy's copy of the list and must be readable: %v", err)
}
ver := regexp.MustCompile(`\d+\.\d+\.\d+`)
var marked, precheck []string
for _, line := range strings.Split(string(b), "\n") {
switch {
case strings.Contains(line, "<!-- spoiled-controller-tags -->"):
for _, m := range regexp.MustCompile("`(\\d+\\.\\d+\\.\\d+)`").FindAllStringSubmatch(line, -1) {
marked = append(marked, m[1])
}
case strings.HasPrefix(line, "VER=<VER>; case \""):
q := line[len("VER=<VER>; case \""):]
precheck = ver.FindAllString(q[:strings.Index(q, "\"")], -1)
}
}
want := SpoiledControllerTags()
if !reflect.DeepEqual(marked, want) {
t.Errorf("the skill's marked never-deploy line lists %v, the hub %v", marked, want)
}
if !reflect.DeepEqual(precheck, want) {
t.Errorf("the skill's step-3 pre-check refuses %v, the hub %v", precheck, want)
}
}
+17
View File
@@ -2431,6 +2431,11 @@ type ManagedFloorDecision struct {
// MinAgentSourceDeclared (the floor's own declaration, used ONLY above the golden). "" = no
// requirement at all (an uncoupled release).
MinAgentSource string
// HeldSpoiled is the third hold reason (R-1085): the floor names a WITHDRAWN controller build
// (SpoiledControllerTag). It wins over the other two — no agent check makes a faulty image safe.
HeldSpoiled bool
// SpoiledReason says why that build is withdrawn ("" unless HeldSpoiled).
SpoiledReason string
}
// Where a managed floor's agent requirement came from. See ManagedFloorDecision.MinAgentSource.
@@ -2452,6 +2457,9 @@ func (d ManagedFloorDecision) HoldReason() string {
switch {
case !d.Held:
return ""
case d.HeldSpoiled:
return fmt.Sprintf("held: floor %s is a WITHDRAWN controller build (%s) — set another version (R-1085)",
d.EffectiveFloorForMessage(), d.SpoiledReason)
case d.HeldBeyondGolden:
return fmt.Sprintf("held: floor %s is ABOVE the vouched golden %s, so its agent requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1)",
d.EffectiveFloorForMessage(), goldenOrUnknown(d.GoldenVersion))
@@ -2511,6 +2519,7 @@ func agentOrUnknownVersion(v string) string {
//
// Logic:
// - effective floor "" → nothing to serve (no floor configured);
// - effective floor is a WITHDRAWN build (spoiled_tags.go) → HOLD + HeldSpoiled (R-1085);
// - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating);
// - floor ABOVE the vouched golden's controller → HOLD (requirement unknown) + flag;
// - agent_version known AND ≥ MinAgent → serve the floor;
@@ -2523,6 +2532,14 @@ func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision {
if d.Floor == "" {
return d
}
// R-1085: a withdrawn build is never served, whatever put it here — the forms refuse it, but a value stored
// before that refusal existed, or the config/env default, reaches this line unchecked. Checked FIRST: no agent
// comparison makes a faulty image safe. The stored value is left as it is; the pages say why it is held.
if reason, spoiled := SpoiledControllerTag(d.Floor); spoiled {
d.Held, d.HeldSpoiled, d.SpoiledReason = true, true, reason
d.Floor = ""
return d
}
man := s.GetArtifactManifest()
d.MinAgent, d.GoldenVersion = man.MinAgent, man.GoldenVersion
if d.MinAgent != "" {
+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>