feat(hub): host-report client + collector + first daemon loop (slice 3, v0.3.0)

internal/hub: the agent's first daemon — a periodic read-only host-report POSTed to
the hub (the heartbeat; no separate ping).

- HostReport wire contract (shared field-for-field with the hub ingest): host
  metrics, guests (vmid + spec), cloudflared status; storage/backups/restore-tests/
  pbs/audit collections DEFINED but emitted empty (slices 5/6 fill).
- Collector over a read-only proxmoxReader (adapted to the real proxmox surface;
  no proxmox changes) + a CloudflaredProber. Partial-failure: NodeStatus fail = hard
  (skip POST); per-guest GuestConfig fail = status "unknown", still report.
- Client: Bearer-auth POST, standard TLS (system roots / optional ca_file), typed
  TransportError/HTTPError, token never in errors.
- Loop: immediate first report, adopt hub poll_interval (clamp [60,3600]), resilient
  to collect/report errors, clean ctx-cancel shutdown.
- ControlEnvelope: only poll_interval_seconds acted on; blocked/desired_generation/
  has_signed_ops parsed-but-ignored (slice 4).
- config: HubConfig + FELHOM_AGENT_HUB_* overlay + mode-aware HubConfig.Validate +
  WithDefaults + hub-key redaction; example config updated.
- main: no-selftest mode is now the daemon; added --selftest=hub. Version -> 0.3.0.

Tests: report serialization, client (incl. token-redaction), collector partial-
failure, loop continuation+interval adoption, config. internal/proxmox + internal/
authz untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:20:09 +02:00
parent f0fee7e193
commit ab77fa3544
16 changed files with 1352 additions and 91 deletions
+60
View File
@@ -0,0 +1,60 @@
package hub
import (
"encoding/json"
"strings"
"testing"
)
func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) {
r := &HostReport{
HostID: "demo-host-01",
ReportedAt: "2026-06-08T12:00:00Z",
AgentVersion: "0.3.0",
Host: HostMetrics{
Node: "demo-felhom", CPUPercent: 3.2,
MemoryTotalBytes: 16777216000, MemoryUsedBytes: 4194304000, MemoryPercent: 25.0,
DiskTotalBytes: 152000000000, DiskUsedBytes: 30000000000, DiskPercent: 19.7,
LoadAvg: []string{"0.10", "0.20", "0.15"}, UptimeSeconds: 86400,
},
Guests: []Guest{{
VMID: 100, Name: "felhom-cust-acme", Status: "running", ControllerVersion: "",
Spec: &GuestSpec{Cores: 2, MemoryBytes: 2147483648, DiskBytes: 21474836480},
}},
StorageTargets: []StorageTarget{},
Backups: []Backup{},
RestoreTests: []RestoreTest{},
PBSSnapshots: []PBSSnapshot{},
AuditTail: []AuditEntry{},
Cloudflared: Cloudflared{Status: "active"},
}
b, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, field := range []string{
`"host_id":"demo-host-01"`, `"reported_at":`, `"agent_version":"0.3.0"`,
`"cpu_percent":3.2`, `"memory_total_bytes":16777216000`, `"loadavg":["0.10","0.20","0.15"]`,
`"disk_percent":19.7`, `"uptime_seconds":86400`,
`"vmid":100`, `"controller_version":""`, `"memory_bytes":2147483648`,
`"cloudflared":{"status":"active"}`,
// empty collections must be [] not null
`"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`,
} {
if !strings.Contains(got, field) {
t.Errorf("report JSON missing %s\n got: %s", field, got)
}
}
if strings.Contains(got, "null") {
t.Errorf("report JSON must not contain null (empty collections should be []): %s", got)
}
}
func TestGuest_SpecOmittedWhenUnknown(t *testing.T) {
b, _ := json.Marshal(Guest{VMID: 9, Name: "g", Status: "unknown"})
if strings.Contains(string(b), "spec") {
t.Errorf("unknown guest should omit spec, got %s", b)
}
}