Files
felhom-agent/internal/hub/report_test.go
T
admin 14642e3c7b v0.119.0 — the host report carries the box's addresses
A managed box's IP was invisible in every operator surface because nothing
reported one: HostMetrics carried node/cpu/mem/disk/load/uptime/temp/wrapper-sha
and no address of any kind. The hub could not show a host's LAN IP anywhere.

Two things that looked like the answer are traps, both checked before writing
code: lan_resolver.host_ip is an OPTIONAL config value absent unless that feature
is configured, and DeriveHostIP(local_api.listen_addr) returns 169.254.253.1 —
since R-50 the local API binds a link-local address identical on every box. Both
would have produced a confident wrong answer.

New wire field addresses[], one entry per (interface, address). Deliberately
iface+cidr rather than a single lan_ip: a Proxmox host legitimately holds several
(management bridge, tailnet, WG tunnel) and picking one to call "the" LAN IP is a
guess the agent is not entitled to make — silently wrong on a box whose bridge is
not vmbr0. The agent reports what exists; the hub does the labelling.

The filter is one predicate, chosen by MEASURING both demo hosts rather than by
reasoning about interface names. IsGlobalUnicast() alone drops loopback, IPv6
link-local (one per bridge, pure noise) and IPv4 link-local (169.254/16 — exactly
the island address above). It needs no veth/fwbr/tap denylist: that per-guest
plumbing carries no IP at all and self-excludes, verified on both boxes.

No new privilege and no block I/O — net.Interfaces() is a netlink/procfs read, so
the sudoers fence is untouched and the health-check rule is honoured.

The seam DEFAULTS to the real enumerator, inverting the nil-reporter-means-off
convention: this stanza has no config gate, so a forgotten wiring call would have
shipped it silently empty — the inert-seam failure recorded four times here.

Cross-repo: the golden is duplicated byte-identically in felhom.eu and the
contract test fails on top-level key drift, so both goldens moved together and
addresses[0]'s key set is asserted bidirectionally. The field marshals as [],
never null — the repo's own no-nulls invariant caught that on the first run.

Tests +9; three red-proofs (global-unicast filter, down-interface guard, inert
collectAddresses) each run, observed failing, and reverted.
2026-07-31 08:40:58 +02:00

73 lines
2.7 KiB
Go

package hub
import (
"encoding/json"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
)
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,
CPUTempC: intp(47), // nullable scalar — set here so the "no null" invariant stays meaningful
},
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"},
Capabilities: []capability.Status{},
LeafFingerprint: "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245",
Addresses: []HostAddress{},
}
// dr_recipe is always set on the real path (Collect); set it here too so the "no null" invariant
// covers it (empty pbs is omitempty → omitted, never null).
r.DRRecipe = BuildDRRecipeHostHalf(r.Guests, r.StorageTargets, r.PBSSnapshots, ConfiguredBackupTarget{})
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`, `"cpu_temp_c":47`,
`"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":[]`,
`"capabilities":[]`,
`"addresses":[]`,
`"leaf_fingerprint":"60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"`,
} {
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)
}
}