hub v0.155.0: the floor can never deliver a withdrawn controller build (R-1085); RomM/Ghost evidence
gates / gates (push) Successful in 6m44s
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:
1 parent
920c1aaf84
commit
b17e9e993c
28 files changed
+955
-6
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user