hub v0.7.2: ingest agent storage_targets (slice 5 Phase A)

Accept + persist the now-populated host-report storage_targets. Minimal — the
authoritative storage manifest is hub-owned (slice 10); this mirrors what the agent
observes.

- hostReportPayload.StorageTargets: full mirror of the agent's hub.StorageTarget
  wire contract; persisted verbatim in report_json (no schema change); count +
  WARN on disconnected targets.
- shared host-report golden updated with two populated targets; byte-identical with
  felhom-agent's copy.
- TestHostStorageTarget_GoldenContract: hub half of the bidirectional key-set test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:59:27 +02:00
parent 2f8658981d
commit aaff268fff
5 changed files with 249 additions and 125 deletions
+64 -5
View File
@@ -234,9 +234,15 @@ const defaultHostPollSeconds = 900
const maxHostReportBytes = 4 << 20 // 4 MiB
// hostReportPayload is the subset of the agent host-report (slice-3 contract,
// §3 / agent spec §4) the hub needs for denorm + guest reality. Unknown fields
// (storage_targets/backups/restore_tests/pbs_snapshots/audit_tail) are ignored,
// so an empty or absent collection is accepted without error.
// §3 / agent spec §4) the hub needs for denorm + guest reality. The remaining fields
// (backups/restore_tests/pbs_snapshots/audit_tail) are ignored, so an empty or absent
// collection is accepted without error.
//
// storage_targets (slice 5) is now parsed: the agent populates it, and the hub accepts
// + persists it. Persistence is the full report_json row (which carries the targets
// verbatim) plus the denorm counts below — the RICH manifest schema (desired class/role/
// policy/creds) is hub-owned and lands in slice 10; this slice only mirrors what the agent
// observes.
type hostReportPayload struct {
HostID string `json:"host_id"`
AgentVersion string `json:"agent_version"`
@@ -251,11 +257,49 @@ type hostReportPayload struct {
Status string `json:"status"`
ControllerVersion string `json:"controller_version"`
} `json:"guests"`
Cloudflared struct {
StorageTargets []hostStorageTarget `json:"storage_targets"`
Cloudflared struct {
Status string `json:"status"`
} `json:"cloudflared"`
}
// hostStorageTarget mirrors the agent's hub.StorageTarget wire contract field-for-field.
// It is a DUPLICATED contract (no shared types module yet); testdata/host-report.golden.json
// must stay byte-identical with felhom-agent's copy and the key-set test guards drift.
// The hub does not act on these yet beyond persisting + counting them (slice 10 adds the
// authoritative manifest), but mirroring the full shape keeps the cross-repo contract honest.
type hostStorageTarget struct {
Name string `json:"name"`
Type string `json:"type"`
DurableID string `json:"durable_id"`
State string `json:"state"`
Reachable bool `json:"reachable"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
AvailBytes int64 `json:"avail_bytes"`
UsedFraction float64 `json:"used_fraction"`
Content string `json:"content"`
MountPath string `json:"mount_path"`
BackingDevice string `json:"backing_device"`
ClassHint string `json:"class_hint"`
Role string `json:"role"`
ThinPool *struct {
DataUsedFraction float64 `json:"data_used_fraction"`
MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
} `json:"thin_pool,omitempty"`
Smart struct {
Health string `json:"health"`
TemperatureC *int `json:"temperature_c"`
PowerOnHours *int `json:"power_on_hours"`
ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"`
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"`
PercentageUsed *int `json:"percentage_used"`
} `json:"smart"`
}
// handleHostReport ingests the agent's host-report (the heartbeat) and returns the
// control envelope (agent spec §5).
func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
@@ -340,7 +384,22 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
}
}
h.logger.Printf("[INFO] host-report from %s (%d guests, %d bytes)", hostID, len(rep.Guests), len(body))
// storage_targets (slice 5): persisted as part of report_json above. Count + surface
// disconnected ones in the log (the slice-10 manifest will reconcile them; for now the
// signal is the visibility — a disconnected target is the storage analog of host-down).
disconnected := 0
for _, st := range rep.StorageTargets {
if st.State == "disconnected" {
disconnected++
}
}
if disconnected > 0 {
h.logger.Printf("[WARN] host %s reports %d disconnected storage target(s) of %d",
hostID, disconnected, len(rep.StorageTargets))
}
h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d bytes)",
hostID, len(rep.Guests), len(rep.StorageTargets), len(body))
blocked := false
if cc, err := h.store.GetCustomerConfig(custID); err == nil && cc != nil && cc.Status == "blocked" {
+76
View File
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
@@ -229,4 +231,78 @@ func TestHostReport_GoldenContract(t *testing.T) {
if guestCount != 2 {
t.Errorf("guests upserted = %d, want 2", guestCount)
}
// storage_targets (slice 5) must be persisted verbatim in report_json.
var reportJSON string
if err := db.QueryRow(`SELECT report_json FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
Scan(&reportJSON); err != nil {
t.Fatal(err)
}
var parsed hostReportPayload
if err := json.Unmarshal([]byte(reportJSON), &parsed); err != nil {
t.Fatalf("persisted report_json does not parse: %v", err)
}
if len(parsed.StorageTargets) != 2 {
t.Errorf("persisted storage_targets = %d, want 2", len(parsed.StorageTargets))
}
if parsed.StorageTargets[0].Type != "lvmthin" || parsed.StorageTargets[0].ThinPool == nil {
t.Errorf("storage_targets[0] = %+v, want lvmthin with thin_pool", parsed.StorageTargets[0])
}
}
// TestHostStorageTarget_GoldenContract asserts the hub's hostStorageTarget mirror covers
// the golden's storage_targets[0] field-for-field (the bidirectional key-set test, the hub
// half of the cross-repo contract). It round-trips the golden element through the mirror
// struct and requires the re-marshaled key set to match exactly — neither missing a field
// the agent sends nor inventing one it doesn't.
func TestHostStorageTarget_GoldenContract(t *testing.T) {
raw, err := os.ReadFile("testdata/host-report.golden.json")
if err != nil {
t.Fatal(err)
}
var golden struct {
StorageTargets []json.RawMessage `json:"storage_targets"`
}
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatal(err)
}
if len(golden.StorageTargets) == 0 {
t.Fatal("golden has no storage_targets to check")
}
var goldenKeys map[string]any
json.Unmarshal(golden.StorageTargets[0], &goldenKeys)
var mirror hostStorageTarget
if err := json.Unmarshal(golden.StorageTargets[0], &mirror); err != nil {
t.Fatalf("golden storage target does not parse into the mirror: %v", err)
}
b, _ := json.Marshal(mirror)
var mirrorKeys map[string]any
json.Unmarshal(b, &mirrorKeys)
assertSameStorageKeys(t, "storage_targets[0]", goldenKeys, mirrorKeys)
assertSameStorageKeys(t, "storage_targets[0].smart", goldenKeys["smart"], mirrorKeys["smart"])
assertSameStorageKeys(t, "storage_targets[0].thin_pool", goldenKeys["thin_pool"], mirrorKeys["thin_pool"])
}
func assertSameStorageKeys(t *testing.T, where string, a, b any) {
t.Helper()
ka, kb := sortedKeys(a), sortedKeys(b)
if !reflect.DeepEqual(ka, kb) {
t.Errorf("contract drift at %s:\n golden keys = %v\n mirror keys = %v", where, ka, kb)
}
}
func sortedKeys(v any) []string {
m, ok := v.(map[string]any)
if !ok {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+57 -1
View File
@@ -29,7 +29,63 @@
"controller_version": ""
}
],
"storage_targets": [],
"storage_targets": [
{
"name": "local-lvm",
"type": "lvmthin",
"durable_id": "pve/data",
"state": "attached",
"reachable": true,
"total_bytes": 100000000000,
"used_bytes": 42000000000,
"avail_bytes": 58000000000,
"used_fraction": 0.42,
"content": "rootdir,images",
"mount_path": "",
"backing_device": "",
"class_hint": "fast",
"role": "",
"thin_pool": { "data_used_fraction": 0.42, "metadata_used_fraction": null },
"smart": {
"health": "UNKNOWN",
"temperature_c": null,
"power_on_hours": null,
"reallocated_sectors": null,
"pending_sectors": null,
"offline_uncorrectable": null,
"critical_warning": null,
"media_errors": null,
"percentage_used": null
}
},
{
"name": "usb-backup",
"type": "usb",
"durable_id": "uuid:0fc63daf-8483-4772-8e79-3d69d8477de4",
"state": "attached",
"reachable": true,
"total_bytes": 2000000000000,
"used_bytes": 500000000000,
"avail_bytes": 1500000000000,
"used_fraction": 0.25,
"content": "backup",
"mount_path": "/mnt/usb-backup",
"backing_device": "/dev/sdb1",
"class_hint": "slow",
"role": "",
"smart": {
"health": "UNKNOWN",
"temperature_c": null,
"power_on_hours": null,
"reallocated_sectors": null,
"pending_sectors": null,
"offline_uncorrectable": null,
"critical_warning": null,
"media_errors": null,
"percentage_used": null
}
}
],
"backups": [],
"restore_tests": [],
"pbs_snapshots": [],