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
+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