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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user