hub v0.130.0: OS updates, guest fast lane — rings, per-box switch, OS releases approved from ring 0, os-report, os_update desired block
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -50,6 +50,7 @@ type Poker interface {
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
offsiteKeys OffsiteKeyService // decision 69 key registrar (nil → 503)
|
||||
osUpdates OSUpdateService // `11` §8 step 2, the guest fast lane (nil → 503, no block merged)
|
||||
apiKey string
|
||||
resendAPIKey string
|
||||
fromEmail string
|
||||
@@ -288,6 +289,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/pbs/consume-token"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/pbs/consume-token")
|
||||
h.handleConsumePBSToken(w, r, hostID)
|
||||
// OS updates (hub v0.130.0): the agent's report after every OS-leg run — per-host key, self-scoped.
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/os-report"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/os-report")
|
||||
h.handleOSReport(w, r, hostID)
|
||||
// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
|
||||
@@ -1747,6 +1752,7 @@ func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request,
|
||||
// S2: merge the hub-OWNED wireguard block at read time (no peer → pass-through unchanged;
|
||||
// the stored operator blob is never modified). See api/wg.go mergeWireguard.
|
||||
desired = h.mergeWireguard(pathHostID, desired)
|
||||
desired = h.mergeOSUpdate(pathHostID, desired)
|
||||
resp := map[string]interface{}{
|
||||
"generation": host.DesiredGeneration,
|
||||
"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
|
||||
)
|
||||
|
||||
// OSUpdateService is the hub half of the guest fast lane (`11` §8 step 2; hub v0.130.0).
|
||||
type OSUpdateService interface {
|
||||
Ingest(hostID string, r osupdates.Report) error
|
||||
DesiredBlock(hostID string) osupdates.Block
|
||||
}
|
||||
|
||||
// SetOSUpdateService wires the OS-update service. nil → the report endpoint answers 503 and no block is merged.
|
||||
func (h *Handler) SetOSUpdateService(s OSUpdateService) { h.osUpdates = s }
|
||||
|
||||
// handleOSReport: POST /api/v1/hosts/{id}/os-report — the agent's report after every OS-leg run. Per-host key,
|
||||
// SELF-SCOPED (a host reports only for itself).
|
||||
func (h *Handler) handleOSReport(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if pathHostID == "" || (!isGlobal && authHostID != pathHostID) {
|
||||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if h.osUpdates == nil {
|
||||
http.Error(w, "os updates not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var rep osupdates.Report
|
||||
if err := json.Unmarshal(body, &rep); err != nil || rep.RunID == "" {
|
||||
http.Error(w, "body must be an os report with run_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.osUpdates.Ingest(pathHostID, rep); err != nil {
|
||||
h.logger.Printf("[WARN] os-report from %s: %v", pathHostID, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// mergeOSUpdate adds the hub-OWNED `os_update` block to a host's desired state at read time (like
|
||||
// mergeWireguard): the stored operator blob is never modified. No service → pass-through unchanged.
|
||||
func (h *Handler) mergeOSUpdate(hostID, desired string) string {
|
||||
if h.osUpdates == nil {
|
||||
return desired
|
||||
}
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(desired), &doc); err != nil {
|
||||
h.logger.Printf("[ERROR] os_update merge %s: stored desired_json unparsable: %v (serving unmerged)", hostID, err)
|
||||
return desired
|
||||
}
|
||||
doc["os_update"] = h.osUpdates.DesiredBlock(hostID)
|
||||
out, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return desired
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// The os_update block a box receives is a contract DUPLICATED with felhom-agent:
|
||||
// testdata/desired-state-osupdate.golden.json MUST stay byte-identical with the agent's
|
||||
// internal/hub/testdata copy (the agent's test decodes it). This test proves the hub SERVES exactly that shape.
|
||||
func TestOSUpdate_DesiredBlockMatchesTheGolden(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
seedHost(t, st, "h1", "c1", "HKEY1")
|
||||
svc := &osupdates.Service{Store: st, ApproveAfter: 0, NightsRequired: 0, Logger: log.New(os.Stderr, "", 0)}
|
||||
h.SetOSUpdateService(svc)
|
||||
rel := `[{"name":"libc6","version":"2.41-12+deb13u4","origin":"Debian"},{"name":"openssl","version":"3.5.7-1~deb13u3","origin":"Debian-Security"}]`
|
||||
if err := st.SaveOSRelease(storeRelease("os-20261004-120000", rel)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET desired-state: %d", rr.Code)
|
||||
}
|
||||
var got struct {
|
||||
DesiredState struct {
|
||||
OSUpdate json.RawMessage `json:"os_update"`
|
||||
} `json:"desired_state"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &got)
|
||||
raw, err := os.ReadFile("testdata/desired-state-osupdate.golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var golden struct {
|
||||
DesiredState struct {
|
||||
OSUpdate json.RawMessage `json:"os_update"`
|
||||
} `json:"desired_state"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &golden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var a, b any
|
||||
json.Unmarshal(got.DesiredState.OSUpdate, &a)
|
||||
json.Unmarshal(golden.DesiredState.OSUpdate, &b)
|
||||
ab, _ := json.Marshal(a)
|
||||
bb, _ := json.Marshal(b)
|
||||
if string(ab) != string(bb) {
|
||||
t.Fatalf("served os_update diverged from the golden:\n served: %s\n golden: %s", ab, bb)
|
||||
}
|
||||
}
|
||||
|
||||
// A box reports only for itself; another box's key is refused and nothing is stored.
|
||||
func TestOSReport_SelfScoped(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
seedHost(t, st, "h1", "c1", "HKEY1")
|
||||
seedHost(t, st, "h2", "c2", "HKEY2")
|
||||
h.SetOSUpdateService(&osupdates.Service{Store: st})
|
||||
body := `{"run_id":"r1","trigger":"night","mode":"apply","outcome":"applied","healthy":true,"upgraded":[{"name":"libc6","version":"x","origin":"Debian"}]}`
|
||||
if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY2", body); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-host report → %d, want 403", rr.Code)
|
||||
}
|
||||
if r, _ := st.LatestOSReport("h1"); r != nil {
|
||||
t.Fatal("a refused report was stored")
|
||||
}
|
||||
if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY1", body); rr.Code != http.StatusOK {
|
||||
t.Fatalf("own report → %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if r, _ := st.LatestOSReport("h1"); r == nil || r.Outcome != "applied" {
|
||||
t.Fatalf("report not stored: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func storeRelease(id, pkgs string) store.OSRelease {
|
||||
at, _ := time.Parse(time.RFC3339, "2026-10-04T12:00:00Z")
|
||||
return store.OSRelease{ID: id, Fingerprint: "fp", ApprovedAt: at, ApprovedBy: "auto", PackagesJSON: pkgs}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"generation": 1,
|
||||
"desired_state": {
|
||||
"os_update": {
|
||||
"ring": 1,
|
||||
"enabled": true,
|
||||
"release": {
|
||||
"id": "os-20261004-120000",
|
||||
"snapshot": "20261004T120000Z",
|
||||
"packages": [
|
||||
{"name": "libc6", "version": "2.41-12+deb13u4", "origin": "Debian"},
|
||||
{"name": "openssl", "version": "3.5.7-1~deb13u3", "origin": "Debian-Security"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user