diff --git a/documentation/runbooks/publish-train-rules.md b/documentation/runbooks/publish-train-rules.md index ac9fae2..5eb6b3f 100644 --- a/documentation/runbooks/publish-train-rules.md +++ b/documentation/runbooks/publish-train-rules.md @@ -25,14 +25,28 @@ zero NAS shares. **Rule:** fill the floor field **last**, in a separate save, only after rule 3's fleet check passes. -## 3. MinAgent fleet gate +## 3. MinAgent fleet gate — HUB-ENFORCED PER-BOX since hub v0.45.0 A controller release that depends on coupled agent behavior declares `MinAgent: X.Y.Z` in its CHANGELOG entry header line (felhom-controller convention, since v0.114.0; retroactively, 0.113.0's -effective MinAgent was 0.81.0 for the NAS add). **The floor may not rise past a controller version -whose MinAgent is not yet met fleet-wide:** check the hub dashboard — every reporting box must show -agent ≥ MinAgent before the floor save. Manual at the current fleet size; hub-enforced per-box -gating is the deferred option-3. +effective MinAgent was 0.81.0 for the NAS add). The floor may not effectively push a box past a +controller whose MinAgent that box's agent does not yet meet. + +**Hub-enforced per-box since hub v0.45.0 (the manual fleet check is retired):** the operator sets +the golden's **`MinAgent`** in the Day-0 artifact manifest (Configuration → Day-0 artifacts → +"Min agent"; blank = uncoupled release, no gating). At report-ACK time the hub compares each box's +reported `hosts.agent_version` against that MinAgent +(`store.ResolveManagedFloor`, `hub/internal/store/store.go`): agent ≥ MinAgent → the controller +floor is served; agent below MinAgent **or unknown** → the floor is **HELD** (the ACK omits the +directive) and the box is flagged on the Hosts dashboard (`floor held: agent < MinAgent `) — +a held box is visible, never silently stale. The operator still SETS MinAgent at manifest time; the +hub does the per-box gating. This mechanises the "agent BEFORE controller floor" ordering that rule +2's incident violated by hand. + +Effective-floor visibility (same v0.45.0): the floor card shows the resolved value + its source +(DB `hub_settings` vs env `DEFAULT_MIN_CONTROLLER_VERSION`, both raw values when they differ), and +the floor save is behind a type-to-confirm dialog stating the live below-floor blast radius — so +rule 2's "the floor acts immediately" is impossible to miss. ## 4. Box-level backstop: the controller's capability gate @@ -42,6 +56,9 @@ transport/5xx ⇒ indeterminate, never "too old") and refuses up front with an h message instead of failing mid-pipeline. This turns a violated ordering into a graceful refusal — it does **not** license sloppy trains: rules 1–3 remain the primary control. -Convention for new coupled features: add a row to the `featureProbes` table + a `Supports` gate -call at the feature's entry point, and declare MinAgent per rule 3. When the agent someday reports -an explicit version in its envelope, `Supports` should prefer the version comparison (roadmap). +Convention for new coupled features: add a row to the `featureProbes` table AND the `featureMinAgent` +table + a `Supports` gate call at the feature's entry point, and declare MinAgent per rule 3. Since +controller v0.115.0 + agent v0.82.0 the agent reports its version in the `X-Felhom-Agent-Version` +response header, so `Supports` decides by version comparison when the version is known and only +falls back to the route probe for header-less (≤0.81) agents — capability detection is now explicit, +not probe-inferred. diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 79b4b7e..88287de 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,32 @@ # Felhom Hub — Changelog +## v0.45.0 — floor-UI separation + effective-floor source + per-box MinAgent conditional floor (2026-07-11) + +Two parts of the NAS/coupling backlog, both addressing the publish-train 0.81/0.113 floor footguns. + +- **Floor-UI separation + confirm (Part C):** the global controller-version floor is its own card with + a **type-to-confirm** dialog that first states the live blast radius — + `GET /configuration/global-floor/impact?v=X.Y.Z` (`countBoxesBelowFloor`, honoring per-customer + overrides) — so "the floor acts immediately" is impossible to miss. An **effective-floor + source + line** (`store.ResolveGlobalFloor` → `GlobalFloorResolution`) shows the resolved value and WHICH + source won (**DB `hub_settings`** vs **env `DEFAULT_MIN_CONTROLLER_VERSION`**, both raw values when + they differ) — the 9-minute-skew incident's root cause, now permanently visible. The Day-0 artifact + manifest save is unchanged and provably does NOT touch the floor (regression-tested). +- **Per-box MinAgent conditional floor (Part D):** the artifact manifest gains **`MinAgent`** (the + golden's controller `MinAgent:` header; blank = uncoupled, no gating). At report-ACK time + `store.ResolveManagedFloor(customerID)` compares the box's `hosts.agent_version` against it: agent ≥ + MinAgent → the controller floor is served; below **or unknown** → the floor is **HELD** (ACK omits + the directive) and the box is flagged on the Hosts dashboard (`floor held: agent < MinAgent + `). Mechanises the "agent BEFORE controller floor" rule per box — the manual fleet check is + retired (publish-train-rules.md rule 3 updated). +- **THE one comparator:** `web.compareVersions`'s body moves to a leaf `internal/semver` package + (`Compare`/`Valid`); web delegates, store's MinAgent gate reuses it (no import cycle, no second + comparator; gitea's documented local copy is out of scope). +- Tests + red-proofs: floor source precedence (DB-wins), manifest-save-doesn't-touch-floor, + impact-count with override exclusion, source-line render; managed-floor hold/serve/uncoupled/ + unknown-agent + a fleet discriminator + the report-ACK wire test (held box omits the floor, served + once the agent qualifies). Every red-proof run → predicted failure → reverted. + ## v0.44.0 — PBS DR tier SLICE 1: ep0 tenantsync surface + hub provisioning (2026-07-10) Builds on SPIKE-pbs-tier-provisioning (00afadc). The operator ticks "PBS DR tier (ep0)" on a diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 5d38225..0b02f3c 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -381,8 +381,15 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { // version against the floor and auto-updates when below it (latest stays the customer's opt-in // "update to latest" button — NOT the auto-target). Both fields are omitted when empty, so an old // controller that ignores them, or a hub with no floor configured, behaves exactly as before. - if floor := h.store.EffectiveMinControllerVersion(payload.CustomerID); floor != "" { - resp["min_controller_version"] = floor + // Part D: the per-box MinAgent conditional floor — HOLD the controller-version floor for a box + // whose host agent is below the current golden's required MinAgent (never push a controller past + // the agent it depends on). A held box gets NO directive (behaves as if no floor) but is flagged + // on the dashboard, never silently stale. + if fd := h.store.ResolveManagedFloor(payload.CustomerID); fd.Floor != "" { + resp["min_controller_version"] = fd.Floor + } else if fd.Held { + h.logger.Printf("[INFO] managed floor HELD for %s: agent %q < MinAgent %s (controller floor withheld)", + payload.CustomerID, fd.AgentVersion, fd.MinAgent) } if h.latestVersion != nil { if latest := h.latestVersion.LatestVersion(); latest != "" { diff --git a/hub/internal/api/managed_floor_ack_test.go b/hub/internal/api/managed_floor_ack_test.go new file mode 100644 index 0000000..08abee4 --- /dev/null +++ b/hub/internal/api/managed_floor_ack_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// Part D over the wire: the report ACK WITHHOLDS the controller floor for a box whose agent is below +// the golden's MinAgent, and serves it once the agent qualifies. Companion red-proof: revert the +// report handler to the ungated EffectiveMinControllerVersion → the held case leaks the floor. +func TestReportACK_ManagedFloorHold(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SetDefaultMinControllerVersion("0.113.0") // controller floor + _ = st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: "0.81.0"}) // coupled golden + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}"}); err != nil { + t.Fatal(err) + } + if err := st.UpsertHost(&store.Host{HostID: "hc", CustomerID: "c", APIKey: "hc-key"}); err != nil { + t.Fatal(err) + } + + ackFloor := 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 ack map[string]interface{} + _ = json.Unmarshal(rr.Body.Bytes(), &ack) + v, ok := ack["min_controller_version"] + return v, ok + } + + // Agent below MinAgent → the floor is HELD (ACK omits it). + if err := st.SaveHostReport("hc", "c", []byte(`{}`), store.HostReportDenorm{AgentVersion: "0.79.0"}); err != nil { + t.Fatal(err) + } + if v, ok := ackFloor(); ok { + t.Errorf("held box: ACK must OMIT the floor, got %v", v) + } + + // Agent updates to ≥ MinAgent → the floor is now served. + if err := st.SaveHostReport("hc", "c", []byte(`{}`), store.HostReportDenorm{AgentVersion: "0.82.0"}); err != nil { + t.Fatal(err) + } + if v, ok := ackFloor(); !ok || v != "0.113.0" { + t.Errorf("qualified box: ACK must serve the floor 0.113.0, got %v (present=%v)", v, ok) + } +} diff --git a/hub/internal/semver/semver.go b/hub/internal/semver/semver.go new file mode 100644 index 0000000..c22fd1d --- /dev/null +++ b/hub/internal/semver/semver.go @@ -0,0 +1,51 @@ +// Package semver is THE hub's controller/agent version comparator (house rule: one comparator). +// Extracted from web.compareVersions (v0.45.0) so the store's managed-floor MinAgent gate can share +// it without an import cycle (store/api don't import web). web.compareVersions now delegates here. +package semver + +import ( + "strconv" + "strings" +) + +// Compare returns >0 if a>b, 0 if equal, <0 if a= 0 { + return d // agent is new enough — serve the floor + } + // Agent too old, or unknown/unparseable → hold the floor (never push a controller past its agent). + d.Held = true + d.Floor = "" + return d +} + // IsCustomerBlocked returns true if the customer config has status "blocked". func (s *Store) IsCustomerBlocked(customerID string) bool { var status string diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index d82559c..9b7221a 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -745,6 +745,48 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther) } +// countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the +// proposed global) would exceed their reported controller version — i.e. how many boxes a proposed +// global-floor save would immediately push into an update. Boxes with a per-customer override are +// governed by that override, not the proposed global, so they are excluded from the global-save +// blast radius (the confirm dialog is about the GLOBAL knob). Reused by the confirm-count endpoint. +func (s *Server) countBoxesBelowFloor(proposedGlobal string) int { + customers, err := s.store.GetCustomers() + if err != nil { + return 0 + } + configs, _ := s.store.ListCustomerConfigs() + override := make(map[string]string, len(configs)) + for _, c := range configs { + if c.MinControllerVersion != "" { + override[c.CustomerID] = c.MinControllerVersion + } + } + n := 0 + for _, c := range customers { + floor := proposedGlobal + if ov, ok := override[c.CustomerID]; ok { + floor = ov // an overridden box is not moved by the global knob + } + if floor != "" && c.ControllerVersion != "" && compareVersions(floor, c.ControllerVersion) > 0 { + n++ + } + } + return n +} + +// handleGlobalFloorImpact answers the confirm dialog's "how many boxes are below ?" probe +// (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")) + 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}) +} + // handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed // updates). Empty clears the hub_settings override, falling back to the config/env default. func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { @@ -796,7 +838,8 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { } agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version")) goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version")) - if !okAV || !okGV { + minAgent, okMA := normalizeFloorInput(r.FormValue("min_agent")) // Part D: empty = uncoupled release + if !okAV || !okGV || !okMA { http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther) return } @@ -811,12 +854,13 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { AgentSHA256: agentSHA, GoldenVersion: goldenVer, GoldenSHA256: goldenSHA, + MinAgent: minAgent, }); err != nil { s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } - s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer) + s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q", agentVer, goldenVer, minAgent) http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther) } diff --git a/hub/internal/web/floor_ui_test.go b/hub/internal/web/floor_ui_test.go new file mode 100644 index 0000000..b74a094 --- /dev/null +++ b/hub/internal/web/floor_ui_test.go @@ -0,0 +1,154 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// seedReport writes a latest report carrying a controller version for customerID. +func seedReport(t *testing.T, st *store.Store, customerID, ctrlVer string) { + t.Helper() + if err := st.SaveReport(customerID, []byte(`{"controller_version":"`+ctrlVer+`"}`)); err != nil { + t.Fatalf("SaveReport(%s): %v", customerID, err) + } +} + +// TestManifestSave_DoesNotTouchFloor is Part C's central guarantee: saving the Day-0 artifact +// manifest must NOT write hub_settings.min_controller_version (the publish-train footgun). Companion +// red-proof: add a SetGlobalMinControllerVersion call into handleSetArtifacts → the floor changes → +// this fails. +func TestManifestSave_DoesNotTouchFloor(t *testing.T) { + s, st := newTestServer(t) + st.SetDefaultMinControllerVersion("0.87.0") + if err := st.SetGlobalMinControllerVersion("0.113.0"); err != nil { + t.Fatal(err) + } + before := st.ResolveGlobalFloor() + + form := url.Values{"agent_version": {"0.82.0"}, "golden_version": {"0.115.0"}} + // No Gitea client in the test → resolveArtifactSHA takes the manual path; provide valid shas. + form.Set("agent_sha256", strings.Repeat("a", 64)) + form.Set("golden_sha256", strings.Repeat("b", 64)) + r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleSetArtifacts(w, r) + + if m := st.GetArtifactManifest(); m.AgentVersion != "0.82.0" || m.GoldenVersion != "0.115.0" { + t.Fatalf("manifest not saved: %+v", m) + } + after := st.ResolveGlobalFloor() + if after.Effective != before.Effective || after.Source != before.Source || after.DBValue != before.DBValue { + t.Errorf("manifest save MUST NOT change the floor: before=%+v after=%+v", before, after) + } +} + +// TestGlobalFloorImpact_Count: the confirm-dialog probe counts boxes below a proposed floor, +// honoring per-customer overrides (an overridden box is governed by its own floor, not the proposed +// global). Companion red-proof: drop the override exclusion → the overridden box is miscounted. +func TestGlobalFloorImpact_Count(t *testing.T) { + s, st := newTestServer(t) + // Three reporting boxes. + seedReport(t, st, "below-a", "0.110.0") // below a 0.113 proposal + seedReport(t, st, "below-b", "0.112.0") // below + seedReport(t, st, "current", "0.113.0") // at the proposal (not below) + seedReport(t, st, "overridden", "0.90.0") // below the global BUT has its own override at 0.90.0 + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "overridden", RetrievalPassword: "x", APIKey: "y"}); err != nil { + t.Fatal(err) + } + if err := st.SetMinControllerVersion("overridden", "0.90.0"); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=0.113.0", nil) + w := httptest.NewRecorder() + s.handleGlobalFloorImpact(w, req) + if w.Code != http.StatusOK { + t.Fatalf("impact: %d", w.Code) + } + var resp struct { + Version string `json:"version"` + Valid bool `json:"valid"` + Below int `json:"below"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Valid || resp.Version != "0.113.0" { + t.Fatalf("resp = %+v", resp) + } + // below-a + below-b = 2; current is AT the floor; overridden is governed by its own 0.90.0. + if resp.Below != 2 { + t.Errorf("below-floor count = %d, want 2 (overridden box excluded, at-floor excluded)", resp.Below) + } + + // Invalid version → valid:false, below:0. + req2 := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=notaversion", nil) + w2 := httptest.NewRecorder() + s.handleGlobalFloorImpact(w2, req2) + var resp2 struct { + Valid bool `json:"valid"` + Below int `json:"below"` + } + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + if resp2.Valid || resp2.Below != 0 { + t.Errorf("invalid version → valid=false below=0, got %+v", resp2) + } +} + +// TestConfigurationPage_RendersFloorSource: the effective-floor source line renders both the +// DB-wins and env-fallback states through the production template. +func TestConfigurationPage_RendersFloorSource(t *testing.T) { + render := func(setup func(st *store.Store)) string { + t.Helper() + s, st := newTestServer(t) + setup(st) + req := httptest.NewRequest(http.MethodGet, "/configuration", nil) + w := httptest.NewRecorder() + s.handleConfiguration(w, req) + if w.Code != http.StatusOK { + t.Fatalf("configuration page: %d", w.Code) + } + return w.Body.String() + } + + dbWins := render(func(st *store.Store) { + st.SetDefaultMinControllerVersion("0.87.0") + _ = st.SetGlobalMinControllerVersion("0.113.0") + }) + if !strings.Contains(dbWins, "DB (hub_settings)") || !strings.Contains(dbWins, "v0.113.0") { + t.Errorf("db-wins source line missing:\n%s", excerpt(dbWins)) + } + if !strings.Contains(dbWins, "env fallback would be") || !strings.Contains(dbWins, "v0.87.0") { + t.Errorf("db-wins must also surface the env fallback value:\n%s", excerpt(dbWins)) + } + + envOnly := render(func(st *store.Store) { st.SetDefaultMinControllerVersion("0.87.0") }) + if !strings.Contains(envOnly, "env fallback (DEFAULT_MIN_CONTROLLER_VERSION)") { + t.Errorf("env-fallback source line missing:\n%s", excerpt(envOnly)) + } + // The type-to-confirm wiring must be present (no bare submit button). + if !strings.Contains(envOnly, "confirmGlobalFloor()") || !strings.Contains(envOnly, "global-floor/impact") { + t.Errorf("type-to-confirm dialog wiring missing") + } +} + +func excerpt(s string) string { + if i := strings.Index(s, "Managed updates"); i >= 0 { + end := i + 900 + if end > len(s) { + end = len(s) + } + return s[i:end] + } + if len(s) > 600 { + return s[:600] + } + return s +} diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 6490d86..3c78a77 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -2,6 +2,7 @@ package web import ( "encoding/json" + "fmt" "net/http" "sort" "time" @@ -9,6 +10,15 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) +// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported) +// value to a readable token. +func agentOrUnknown(v string) string { + if v == "" { + return "unknown" + } + return v +} + // hostStatus computes the host's liveness state from its last-report recency, using the // SAME thresholds as the HostStalenessChecker (s.staleThreshold; "down" at 2×). This keeps // the GUI badge in agreement with the alerting — there is no second definition of "stale". @@ -177,6 +187,10 @@ type hostListRow struct { WorstFillPct float64 WorstFillName string HasStorage bool + // FloorHeld (Part D): the managed controller-version floor is being WITHHELD because this box's + // agent is below the current golden's MinAgent. HeldReason carries the operator-facing text. + FloorHeld bool + HeldReason string } // customerName resolves a display name for a customer id (config first, then the last @@ -224,6 +238,13 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) { HasReport: h.LastReportAt != nil, } + // Part D: surface a held managed floor (agent below the golden's MinAgent) so a held box is + // never silently stale. + if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held { + row.FloorHeld = true + row.HeldReason = fmt.Sprintf("held: agent %s < MinAgent %s", agentOrUnknown(fd.AgentVersion), fd.MinAgent) + } + // Guest counts from the reality table (per-host accurate). guests, _ := s.store.ListGuestsForHost(h.HostID) row.GuestTotal = len(guests) diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index bf6de9a..99af3fe 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -11,7 +11,6 @@ import ( "log" "math" "net/http" - "strconv" "strings" "sync" "time" @@ -19,6 +18,7 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/gitea" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" + "gitea.dooplex.hu/admin/felhom-hub/internal/semver" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) @@ -332,6 +332,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleConfigNewForm(w, r) } // Global settings live under the Configuration tab (moved from Customers). + case path == "/configuration/global-floor/impact": + if r.Method == http.MethodGet { + s.handleGlobalFloorImpact(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case path == "/configuration/global-floor": if r.Method == http.MethodPost { s.handleSetGlobalFloor(w, r) @@ -618,28 +624,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { } } -// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b. -// Accepts "X.Y.Z" format. Returns 0 on parse error. -func compareVersions(a, b string) int { - a = strings.TrimPrefix(a, "v") - b = strings.TrimPrefix(b, "v") - aParts := strings.SplitN(a, ".", 3) - bParts := strings.SplitN(b, ".", 3) - if len(aParts) != 3 || len(bParts) != 3 { - return 0 - } - for i := 0; i < 3; i++ { - ai, e1 := strconv.Atoi(aParts[i]) - bi, e2 := strconv.Atoi(bParts[i]) - if e1 != nil || e2 != nil { - return 0 - } - if ai != bi { - return ai - bi - } - } - return 0 -} +// compareVersions delegates to THE hub comparator (internal/semver). Kept as a thin wrapper so the +// web package's many call sites are unchanged. +func compareVersions(a, b string) int { return semver.Compare(a, b) } func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { data, err := templateFS.ReadFile("templates/style.css") @@ -716,6 +703,7 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) { "AssetCount": assetCount, "AssetLastSync": assetLastSync, "GlobalFloor": s.store.GetGlobalMinControllerVersion(), + "FloorRes": s.store.ResolveGlobalFloor(), "Artifacts": s.store.GetArtifactManifest(), "AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent), "GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden), diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index 904d420..ec09b5a 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -48,20 +48,77 @@
Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.
{{end}} - +

Managed updates — global floor

The minimum controller version every box auto-updates to (unless a per-customer override is set). - Boxes below the floor update on their next report — no customer action. Blank = no global floor. + Saving takes effect immediately — boxes below the floor update on their next + report, no customer action. Blank = no global floor. This setting is independent of the Day-0 + artifact manifest below.

-
+ +

+ Effective floor: + {{if .FloorRes.Effective}}v{{.FloorRes.Effective}}{{else}}none{{end}} + {{if eq .FloorRes.Source "db"}} + — source: DB (hub_settings){{if .FloorRes.EnvValue}}; env fallback would be v{{.FloorRes.EnvValue}}{{end}} + {{else if eq .FloorRes.Source "env"}} + — source: env fallback (DEFAULT_MIN_CONTROLLER_VERSION); no DB override set + {{else}} + — no floor from either source + {{end}} +

+ {{.CSRFField}} - - - Current: {{if .GlobalFloor}}v{{.GlobalFloor}}{{else}}unset{{end}} + + + DB override: {{if .FloorRes.DBValue}}v{{.FloorRes.DBValue}}{{else}}none{{end}}
+ +