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:
@@ -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