hub v0.15.0: Phase 2 managed updates — per-customer controller-version floor
Operator sets a minimum controller version (FLOOR), per-customer defaulting to a
global floor; the report ACK returns the effective floor + latest_version so the
controller auto-updates to the floor when below it (latest stays the opt-in button).
- store: min_controller_version column + hub_settings global floor + Effective/
Get/SetGlobal/SetMin resolution + config/env DEFAULT_MIN_CONTROLLER_VERSION
- handler: report ACK {min_controller_version, latest_version}; LatestVersionProvider
- web: global floor editor + per-customer override form + Floor column (English)
- tests: floor resolution + ACK + render; override-precedence red-proof verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSZmmSFVzGwEzhYmxbkgBK
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
type fakeLatest struct{ v string }
|
||||
|
||||
func (f fakeLatest) LatestVersion() string { return f.v }
|
||||
|
||||
// The controller report ACK advertises the effective floor + latest version (Phase 2).
|
||||
func TestReportACK_FloorAndLatest(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
h.SetLatestVersionProvider(fakeLatest{v: "0.86.0"})
|
||||
|
||||
// Customer with a per-customer override; a global default also set (override must win in the ACK).
|
||||
st.SetDefaultMinControllerVersion("0.85.0")
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
|
||||
MinControllerVersion: "0.87.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveCustomerConfig: %v", err)
|
||||
}
|
||||
|
||||
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("report status = %d, want 200 (body=%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var ack map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
|
||||
t.Fatalf("decode ACK: %v", err)
|
||||
}
|
||||
if ack["min_controller_version"] != "0.87.0" {
|
||||
t.Errorf("ACK min_controller_version = %v, want 0.87.0 (override beats global)", ack["min_controller_version"])
|
||||
}
|
||||
if ack["latest_version"] != "0.86.0" {
|
||||
t.Errorf("ACK latest_version = %v, want 0.86.0", ack["latest_version"])
|
||||
}
|
||||
}
|
||||
|
||||
// No floor configured anywhere → the ACK omits min_controller_version (Phase 2 inert).
|
||||
func TestReportACK_NoFloorOmitted(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveCustomerConfig: %v", err)
|
||||
}
|
||||
|
||||
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("report status = %d, want 200", rr.Code)
|
||||
}
|
||||
var ack map[string]interface{}
|
||||
json.Unmarshal(rr.Body.Bytes(), &ack)
|
||||
if _, ok := ack["min_controller_version"]; ok {
|
||||
t.Errorf("ACK should omit min_controller_version when no floor; got %v", ack["min_controller_version"])
|
||||
}
|
||||
// latest_version is also omitted when no provider wired.
|
||||
if _, ok := ack["latest_version"]; ok {
|
||||
t.Errorf("ACK should omit latest_version when no provider; got %v", ack["latest_version"])
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ type ConfigTemplateProvider interface {
|
||||
Template() string
|
||||
}
|
||||
|
||||
// LatestVersionProvider returns the latest controller image version known to the hub's registry
|
||||
// checker (e.g. "0.86.0"), or "" if unknown. Satisfied by *web.VersionChecker; nil when the
|
||||
// registry checker is disabled. Used to advertise latest on the controller report ACK.
|
||||
type LatestVersionProvider interface {
|
||||
LatestVersion() string
|
||||
}
|
||||
|
||||
// Handler handles API endpoints for report ingest and customer queries.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
@@ -35,6 +42,13 @@ type Handler struct {
|
||||
templateProvider ConfigTemplateProvider
|
||||
dispatcher *notify.Dispatcher
|
||||
assetsMgr *assets.Manager
|
||||
latestVersion LatestVersionProvider
|
||||
}
|
||||
|
||||
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
|
||||
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
|
||||
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
|
||||
h.latestVersion = p
|
||||
}
|
||||
|
||||
// New creates a new API handler.
|
||||
@@ -267,6 +281,21 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
resp["customer_blocked"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
|
||||
// else global default) and the latest available version. The controller compares its current
|
||||
// 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
|
||||
}
|
||||
if h.latestVersion != nil {
|
||||
if latest := h.latestVersion.LatestVersion(); latest != "" {
|
||||
resp["latest_version"] = latest
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
Reference in New Issue
Block a user