hub v0.45.0: floor-UI separation + effective-floor source + per-box MinAgent conditional floor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 15:33:14 +02:00
parent 37e60b46d2
commit bbecf0592e
15 changed files with 730 additions and 44 deletions
+25 -8
View File
@@ -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 <v> < MinAgent <w>`) —
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 13 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.
+27
View File
@@ -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 <v> < MinAgent
<w>`). 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
+9 -2
View File
@@ -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 != "" {
@@ -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)
}
}
+51
View File
@@ -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<b. Inputs are bare "X.Y.Z" (a leading "v" is
// tolerated). Returns 0 on any parse error — callers that must not act on a malformed version check
// for it explicitly before comparing.
func Compare(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
ap := strings.SplitN(a, ".", 3)
bp := strings.SplitN(b, ".", 3)
if len(ap) != 3 || len(bp) != 3 {
return 0
}
for i := 0; i < 3; i++ {
ai, e1 := strconv.Atoi(ap[i])
bi, e2 := strconv.Atoi(bp[i])
if e1 != nil || e2 != nil {
return 0
}
if ai != bi {
if ai < bi {
return -1
}
return 1
}
}
return 0
}
// Valid reports whether s is a bare X.Y.Z (v-prefix tolerated) — the shape Compare acts on.
func Valid(s string) bool {
s = strings.TrimPrefix(s, "v")
p := strings.SplitN(s, ".", 3)
if len(p) != 3 {
return false
}
for _, part := range p {
if _, err := strconv.Atoi(part); err != nil {
return false
}
}
return true
}
+38
View File
@@ -0,0 +1,38 @@
package semver
import "testing"
func TestCompare(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"0.81.0", "0.82.0", -1},
{"0.82.0", "0.81.0", 1},
{"0.81.0", "0.81.0", 0},
{"v0.81.0", "0.81.0", 0}, // v-prefix tolerated
{"0.100.0", "0.81.0", 1}, // numeric, not lexicographic
{"1.0.0", "0.99.9", 1}, // major
{"1.2.10", "1.2.3", 1}, // patch numeric
{"garbage", "0.81.0", 0}, // parse error → 0 (callers guard with Valid)
{"0.81", "0.81.0", 0}, // too few parts → 0
}
for _, c := range cases {
if got := Compare(c.a, c.b); got != c.want {
t.Errorf("Compare(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
func TestValid(t *testing.T) {
for _, ok := range []string{"0.81.0", "v0.82.0", "10.20.30"} {
if !Valid(ok) {
t.Errorf("Valid(%q) = false, want true", ok)
}
}
for _, bad := range []string{"", "0.81", "0.81.0-rc1", "a.b.c", "dev"} {
if Valid(bad) {
t.Errorf("Valid(%q) = true, want false", bad)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package store
import "testing"
// ResolveGlobalFloor makes the DB-override-vs-env-fallback precedence explicit (the 9-minute-skew
// incident's root cause: a floor could be armed via the DB with the env still showing the old
// value). Companion red-proof: swap the precedence (env before DB) → the db-wins row fails.
func TestResolveGlobalFloor(t *testing.T) {
t.Run("env only", func(t *testing.T) {
s := newTestStore(t)
s.SetDefaultMinControllerVersion("0.87.0")
r := s.ResolveGlobalFloor()
if r.Effective != "0.87.0" || r.Source != "env" || r.DBValue != "" || r.EnvValue != "0.87.0" {
t.Fatalf("env-only: %+v", r)
}
})
t.Run("db overrides env, both surfaced", func(t *testing.T) {
s := newTestStore(t)
s.SetDefaultMinControllerVersion("0.87.0")
if err := s.SetGlobalMinControllerVersion("0.113.0"); err != nil {
t.Fatal(err)
}
r := s.ResolveGlobalFloor()
if r.Effective != "0.113.0" || r.Source != "db" {
t.Fatalf("db must win: %+v", r)
}
if r.DBValue != "0.113.0" || r.EnvValue != "0.87.0" {
t.Fatalf("both raw values must be surfaced: %+v", r)
}
// The precedence must match GetGlobalMinControllerVersion exactly (no forked rule).
if got := s.GetGlobalMinControllerVersion(); got != r.Effective {
t.Errorf("ResolveGlobalFloor.Effective=%q != GetGlobalMinControllerVersion=%q", r.Effective, got)
}
})
t.Run("none", func(t *testing.T) {
s := newTestStore(t)
r := s.ResolveGlobalFloor()
if r.Effective != "" || r.Source != "none" {
t.Fatalf("none: %+v", r)
}
})
t.Run("cleared db falls back to env", func(t *testing.T) {
s := newTestStore(t)
s.SetDefaultMinControllerVersion("0.87.0")
_ = s.SetGlobalMinControllerVersion("0.113.0")
_ = s.SetGlobalMinControllerVersion("") // clear the override
r := s.ResolveGlobalFloor()
if r.Effective != "0.87.0" || r.Source != "env" {
t.Fatalf("cleared → env: %+v", r)
}
})
}
+95
View File
@@ -0,0 +1,95 @@
package store
import "testing"
// setHostAgent enrolls a host for customerID and records its agent version (the reality column).
func setHostAgent(t *testing.T, s *Store, customerID, hostID, agentVer string) {
t.Helper()
if err := s.UpsertHost(&Host{HostID: hostID, CustomerID: customerID, APIKey: hostID + "-key"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
if agentVer != "" {
if err := s.SaveHostReport(hostID, customerID, []byte(`{}`), HostReportDenorm{AgentVersion: agentVer}); err != nil {
t.Fatalf("SaveHostReport: %v", err)
}
}
}
// ResolveManagedFloor gates the served controller floor on the box's agent meeting the golden's
// MinAgent (Part D — the hub-enforced "agent BEFORE controller floor" rule).
func TestResolveManagedFloor(t *testing.T) {
base := func(t *testing.T) *Store {
s := newTestStore(t)
_ = s.SetGlobalMinControllerVersion("0.113.0") // the controller floor
return s
}
t.Run("uncoupled release (MinAgent empty) → serve the floor, no gating", func(t *testing.T) {
s := base(t)
_ = s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: ""})
setHostAgent(t, s, "c1", "h1", "0.70.0") // ancient agent, but release is uncoupled
fd := s.ResolveManagedFloor("c1")
if fd.Held || fd.Floor != "0.113.0" {
t.Fatalf("uncoupled must serve: %+v", fd)
}
})
t.Run("agent ≥ MinAgent → serve the floor", func(t *testing.T) {
s := base(t)
_ = s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: "0.81.0"})
setHostAgent(t, s, "c1", "h1", "0.82.0")
fd := s.ResolveManagedFloor("c1")
if fd.Held || fd.Floor != "0.113.0" {
t.Fatalf("at/above MinAgent must serve: %+v", fd)
}
})
t.Run("agent < MinAgent → HELD, no floor served, flagged", func(t *testing.T) {
s := base(t)
_ = s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: "0.81.0"})
setHostAgent(t, s, "c1", "h1", "0.79.0")
fd := s.ResolveManagedFloor("c1")
if !fd.Held || fd.Floor != "" {
t.Fatalf("below MinAgent must HOLD: %+v", fd)
}
if fd.AgentVersion != "0.79.0" || fd.MinAgent != "0.81.0" {
t.Fatalf("held decision must carry the inputs: %+v", fd)
}
})
t.Run("agent_version empty (never reported) → HELD (fail-safe)", func(t *testing.T) {
s := base(t)
_ = s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: "0.81.0"})
setHostAgent(t, s, "c1", "h1", "") // enrolled, no report yet
fd := s.ResolveManagedFloor("c1")
if !fd.Held || fd.Floor != "" {
t.Fatalf("unknown agent must HOLD (never push blind): %+v", fd)
}
})
t.Run("no floor configured → nothing to gate", func(t *testing.T) {
s := newTestStore(t) // no global floor
_ = s.SetArtifactManifest(ArtifactManifest{MinAgent: "0.81.0"})
setHostAgent(t, s, "c1", "h1", "0.70.0")
fd := s.ResolveManagedFloor("c1")
if fd.Held || fd.Floor != "" {
t.Fatalf("no floor → no gate, no hold: %+v", fd)
}
})
// Fleet discriminator (the §Part-D companion): in ONE fleet, an at-MinAgent box is served while a
// below-MinAgent box is held. Companion red-proof: drop the hold branch → both get served → the
// held assertion fails.
t.Run("fleet: at-MinAgent served, below-MinAgent held", func(t *testing.T) {
s := base(t)
_ = s.SetArtifactManifest(ArtifactManifest{GoldenVersion: "0.113.0", MinAgent: "0.81.0"})
setHostAgent(t, s, "current", "hc", "0.82.0")
setHostAgent(t, s, "old", "ho", "0.79.0")
if fd := s.ResolveManagedFloor("current"); fd.Held || fd.Floor != "0.113.0" {
t.Errorf("current box must be served: %+v", fd)
}
if fd := s.ResolveManagedFloor("old"); !fd.Held || fd.Floor != "" {
t.Errorf("old box must be held: %+v", fd)
}
})
}
+82 -1
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
_ "modernc.org/sqlite"
)
@@ -1033,6 +1034,36 @@ func (s *Store) GetGlobalMinControllerVersion() string {
return s.defaultMinControllerVersion
}
// GlobalFloorResolution is the full picture of the effective global floor for the operator UI: the
// resolved value + WHICH source won + both raw inputs. It makes the "a manifest save silently armed
// a live floor" incident (publish-train 0.81/0.113) permanently visible — the operator can see the
// DB override vs the env fallback at a glance.
type GlobalFloorResolution struct {
Effective string // the value GetGlobalMinControllerVersion returns ("" = no floor)
Source string // "db" | "env" | "none"
DBValue string // the hub_settings row value ("" = no row / cleared)
EnvValue string // the DEFAULT_MIN_CONTROLLER_VERSION fallback
}
// ResolveGlobalFloor reports the effective floor AND its source (DB hub_settings row vs the env
// default). Mirrors GetGlobalMinControllerVersion's precedence exactly — do not fork the rule.
func (s *Store) ResolveGlobalFloor() GlobalFloorResolution {
res := GlobalFloorResolution{EnvValue: s.defaultMinControllerVersion}
var db string
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = 'min_controller_version'`).Scan(&db); err == nil {
res.DBValue = db
}
switch {
case res.DBValue != "":
res.Effective, res.Source = res.DBValue, "db"
case res.EnvValue != "":
res.Effective, res.Source = res.EnvValue, "env"
default:
res.Source = "none"
}
return res
}
// SetGlobalMinControllerVersion persists the operator-set global floor (overriding the config/env
// default). Pass "" to clear the override and fall back to the default.
func (s *Store) SetGlobalMinControllerVersion(version string) error {
@@ -1056,6 +1087,12 @@ type ArtifactManifest struct {
AgentSHA256 string `json:"agent_sha256"`
GoldenVersion string `json:"golden_version"`
GoldenSHA256 string `json:"golden_sha256"`
// MinAgent is the MINIMUM host-agent version this golden's controller requires (the controller
// CHANGELOG `MinAgent:` value the operator vouches at manifest time). Empty = an UNCOUPLED
// release: no per-box agent gating. When set, the hub HOLDS the controller-version floor for any
// box whose agent is below it (Part D) — mechanising the publish-train "agent BEFORE controller
// floor" rule instead of leaving it to operator discipline.
MinAgent string `json:"min_agent"`
}
// hub_settings keys for the artifact manifest (BUNDLE slice). Stored as discrete key/value rows in
@@ -1066,6 +1103,7 @@ const (
settingArtifactAgentSHA256 = "artifact_agent_sha256"
settingArtifactGoldenVersion = "artifact_golden_version"
settingArtifactGoldenSHA256 = "artifact_golden_sha256"
settingArtifactMinAgent = "artifact_min_agent"
)
// getSetting reads a single hub_settings value ("" if the row is absent).
@@ -1096,6 +1134,7 @@ func (s *Store) GetArtifactManifest() ArtifactManifest {
AgentSHA256: s.getSetting(settingArtifactAgentSHA256),
GoldenVersion: s.getSetting(settingArtifactGoldenVersion),
GoldenSHA256: s.getSetting(settingArtifactGoldenSHA256),
MinAgent: s.getSetting(settingArtifactMinAgent),
}
}
@@ -1111,7 +1150,10 @@ func (s *Store) SetArtifactManifest(m ArtifactManifest) error {
if err := s.setSetting(settingArtifactGoldenVersion, m.GoldenVersion); err != nil {
return err
}
return s.setSetting(settingArtifactGoldenSHA256, m.GoldenSHA256)
if err := s.setSetting(settingArtifactGoldenSHA256, m.GoldenSHA256); err != nil {
return err
}
return s.setSetting(settingArtifactMinAgent, m.MinAgent)
}
// EffectiveMinControllerVersion resolves the floor that actually applies to a customer: the
@@ -1125,6 +1167,45 @@ func (s *Store) EffectiveMinControllerVersion(customerID string) string {
return s.GetGlobalMinControllerVersion()
}
// ManagedFloorDecision is the per-box outcome of the MinAgent conditional floor (Part D): the floor
// to actually serve this customer's controller, whether it is being HELD (and why), and the inputs.
type ManagedFloorDecision struct {
Floor string // the controller-version floor to SERVE ("" = serve none)
Held bool // true = the floor is withheld because the box's agent is below MinAgent
AgentVersion string // the box's reported agent version ("" = unknown → held when MinAgent is set)
MinAgent string // the manifest's MinAgent for the current golden ("" = uncoupled, no gating)
}
// ResolveManagedFloor decides the controller-version floor to serve a customer, HOLDING it when the
// golden the floor points at requires a newer host agent than the box currently runs (Part D — the
// hub-enforced "agent BEFORE controller floor" rule). Logic:
// - effective floor "" → nothing to serve (no floor configured);
// - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating);
// - agent_version known AND ≥ MinAgent → serve the floor;
// - agent_version below MinAgent, OR unknown/unparseable → HOLD (serve no directive) + flag.
// A held box is VISIBLE (the dashboard renders the reason), never silently stale.
func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision {
d := ManagedFloorDecision{Floor: s.EffectiveMinControllerVersion(customerID)}
if d.Floor == "" {
return d
}
d.MinAgent = s.GetArtifactManifest().MinAgent
if d.MinAgent == "" {
return d // uncoupled release — no agent gate
}
if h, err := s.GetHostByCustomer(customerID); err == nil && h != nil {
d.AgentVersion = h.AgentVersion
}
if d.AgentVersion != "" && semver.Valid(d.AgentVersion) && semver.Valid(d.MinAgent) &&
semver.Compare(d.AgentVersion, d.MinAgent) >= 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
+46 -2
View File
@@ -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 <v>?" 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)
}
+154
View File
@@ -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
}
+21
View File
@@ -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)
+11 -23
View File
@@ -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),
+67 -7
View File
@@ -48,20 +48,77 @@
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
{{end}}
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
it is a global setting). -->
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
publish-train 0.81/0.113 incident). Saving acts IMMEDIATELY, so it is behind a
type-to-confirm dialog that states the live below-floor blast radius first. -->
<section class="card">
<h3 style="margin-top: 0;">Managed updates — global floor</h3>
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
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.
<strong>Saving takes effect immediately</strong> — 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.
</p>
<form method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<!-- Effective-floor-and-source line: makes the DB-override-vs-env-fallback precedence
permanently visible (the 9-minute-skew incident's root cause). -->
<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 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"}}
<span style="color: #cbd5e1;">— source: <strong>env fallback (DEFAULT_MIN_CONTROLLER_VERSION)</strong>; no DB override set</span>
{{else}}
<span class="text-muted">— no floor from either source</span>
{{end}}
</p>
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="text" name="min_controller_version" value="{{.GlobalFloor}}" placeholder="e.g. 0.86.0 (blank = none)" style="padding: 0.3em 0.5em; width: 14em;">
<button class="btn btn-sm" type="submit">Save global floor</button>
<span style="font-size: 0.85em; color: #cbd5e1;">Current: {{if .GlobalFloor}}<code>v{{.GlobalFloor}}</code>{{else}}<span class="text-muted">unset</span>{{end}}</span>
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor</button>
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
</form>
<div id="global-floor-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #7c3f00; background: #241a0a; border-radius: 6px; max-width: 44em;">
<p id="global-floor-impact" style="margin: 0 0 0.5rem; font-size: 0.9em;"></p>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: #cbd5e1;">Type the version again to confirm (or <code>CLEAR</code> to remove the DB override):</p>
<input type="text" id="global-floor-confirm-input" placeholder="retype the version…" style="padding: 0.3em 0.5em; width: 16em;">
<button class="btn btn-sm" type="button" onclick="submitGlobalFloor()">Confirm &amp; apply</button>
<button class="btn btn-sm btn-ghost" type="button" onclick="document.getElementById('global-floor-confirm').style.display='none';">Cancel</button>
<p id="global-floor-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: #f87171;"></p>
</div>
<script>
function confirmGlobalFloor() {
var v = document.getElementById('global-floor-input').value.trim();
var box = document.getElementById('global-floor-confirm');
var impact = document.getElementById('global-floor-impact');
document.getElementById('global-floor-confirm-input').value = '';
document.getElementById('global-floor-confirm-err').textContent = '';
box.style.display = 'block';
if (v === '') {
impact.textContent = 'This will CLEAR the DB floor override (the box falls back to the env default). Type CLEAR to confirm.';
return;
}
impact.textContent = 'Checking blast radius…';
fetch('/configuration/global-floor/impact?v=' + encodeURIComponent(v))
.then(function(r){ return r.json(); })
.then(function(d){
if (!d.valid) { impact.textContent = 'Invalid version — use X.Y.Z.'; return; }
impact.textContent = 'Saving the minimum version v' + d.version +
' takes effect immediately — currently ' + d.below +
' box(es) are below this version and would update on their next report.';
})
.catch(function(){ impact.textContent = 'Could not compute the blast radius; proceed with caution.'; });
}
function submitGlobalFloor() {
var v = document.getElementById('global-floor-input').value.trim();
var typed = document.getElementById('global-floor-confirm-input').value.trim();
var err = document.getElementById('global-floor-confirm-err');
var expected = (v === '') ? 'CLEAR' : v;
if (typed !== expected) { err.textContent = 'Confirmation does not match (' + expected + ').'; return; }
document.getElementById('global-floor-form').submit();
}
</script>
</section>
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
@@ -102,6 +159,9 @@
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
{{end}}
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
+1 -1
View File
@@ -45,7 +45,7 @@
<tr onclick="window.location='/hosts/{{.HostID}}'" style="cursor: pointer;">
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</td>
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</td>
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}{{if .FloorHeld}} <span class="status-badge status-warn" title="{{.HeldReason}}">floor held</span>{{end}}</td>
<td><span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span></td>
<td>{{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}</td>
<td>{{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}</td>