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