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