package hub // HostReport is the wire contract shared with the hub's ingest // (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub // field-for-field. Encoding is ordinary encoding/json (no canonicalization — // nothing signs this report; canonical JSON is a slice-10 signing concern). // // The report IS the heartbeat: one periodic POST /api/v1/host-report, whose // server-side received_at is the hub's dead-man's-switch liveness signal. There is // no separate heartbeat endpoint. type HostReport struct { HostID string `json:"host_id"` // echoes config.Hub.HostID ReportedAt string `json:"reported_at"` // RFC3339, agent clock AgentVersion string `json:"agent_version"` Host HostMetrics `json:"host"` Guests []Guest `json:"guests"` // Defined now as the stable contract; emitted EMPTY (non-nil) this slice. StorageTargets []StorageTarget `json:"storage_targets"` // slice 5 (storage manifest) Backups []Backup `json:"backups"` // slice 6 RestoreTests []RestoreTest `json:"restore_tests"` // slice 6 PBSSnapshots []PBSSnapshot `json:"pbs_snapshots"` // slice 6 Cloudflared Cloudflared `json:"cloudflared"` AuditTail []AuditEntry `json:"audit_tail"` // populated by a later slice } // HostMetrics is the host block, sourced from proxmox NodeStatus. type HostMetrics struct { Node string `json:"node"` CPUPercent float64 `json:"cpu_percent"` // 0–100 MemoryTotalBytes int64 `json:"memory_total_bytes"` MemoryUsedBytes int64 `json:"memory_used_bytes"` MemoryPercent float64 `json:"memory_percent"` DiskTotalBytes int64 `json:"disk_total_bytes"` // host root fs DiskUsedBytes int64 `json:"disk_used_bytes"` DiskPercent float64 `json:"disk_percent"` LoadAvg []string `json:"loadavg"` // array of STRINGS (PVE shape) UptimeSeconds int64 `json:"uptime_seconds"` } // Guest is one LXC. The agent reports vmid; the hub derives the guest PK // "/" (keeping the id scheme hub-side — locked decision 4). type Guest struct { VMID int `json:"vmid"` Name string `json:"name"` Status string `json:"status"` // running | stopped | unknown ControllerVersion string `json:"controller_version"` // "" this slice (slice 8 fills) Spec *GuestSpec `json:"spec,omitempty"` // omitted when status unknown } // GuestSpec is the provisioned guest sizing. type GuestSpec struct { Cores int `json:"cores"` MemoryBytes int64 `json:"memory_bytes"` DiskBytes int64 `json:"disk_bytes"` } // Cloudflared is the tunnel service health (read-only probe this slice). type Cloudflared struct { Status string `json:"status"` // active | inactive | failed | unknown } // The following element types are declared now so the empty collections above are // typed and slices 5/6 only fill them. // StorageTarget is one observed host storage target (doc 03 §7). It is the REPORTED // shape — what the agent observes and tells the hub. The hub holds the AUTHORITATIVE // manifest (desired class/role/policy/creds, slice 10); the agent reports what it sees // and reconciles toward the hub's manifest. So `class_hint` is a rotational HINT, never // authoritative class, and `role` is set only when derivable from an existing Proxmox // storage definition (else empty — the hub owns it). // // This is a cross-repo contract DUPLICATED in felhom.eu/hub (no shared module yet); // testdata/host-report.golden.json must stay byte-identical with the hub's copy and the // bidirectional key-set test (contract_test.go) guards drift. type StorageTarget struct { Name string `json:"name"` // the Proxmox storage id (its name) Type string `json:"type"` // local-dir | lvmthin | usb | nfs | cifs | pbs | local DurableID string `json:"durable_id"` // fs-UUID (usb/local-dir) | server:export (nfs/cifs) | repo+fingerprint (pbs) // State is the observed lifecycle state. attached (present & usable) | disconnected // (a KNOWN target whose backing device/mount/reachability dropped) | decommissioned // (hub-manifest state — built but not served until slice 10). State string `json:"state"` Reachable bool `json:"reachable"` // backing device present + mounted (local) / reachable (network) TotalBytes int64 `json:"total_bytes"` UsedBytes int64 `json:"used_bytes"` AvailBytes int64 `json:"avail_bytes"` UsedFraction float64 `json:"used_fraction"` Content string `json:"content"` // Proxmox content list, e.g. "rootdir,images" / "backup,vztmpl" MountPath string `json:"mount_path"` // host mountpoint (dir/usb); "" for network/lvm BackingDevice string `json:"backing_device"` // resolved block device (e.g. /dev/sdb1); "" for network // ClassHint is a fast|slow HINT derived from the backing disk's rotational flag — a // hint only; the authoritative class is hub-owned (locked decision). "" when not // derivable (network targets have no local rotational flag). ClassHint string `json:"class_hint"` // Role is primary|vzdump-target|pbs-offsite|bulk-data when derivable from the existing // storage definition; else "" (the manifest role is hub-owned, slice 10). Role string `json:"role"` // ThinPool carries the lvmthin DATA fill prominently — a full thin-pool corrupts every // guest on it (the storage analog of single-node OOM). Present ONLY for lvmthin targets; // metadata fill is null until Phase B's privileged `lvs` read. ThinPool *ThinPoolFill `json:"thin_pool,omitempty"` // Smart is the disk-health summary, populated in Phase B via smartctl (sudoers). In // Phase A it is {health:"UNKNOWN", } — the keys are committed now so the // contract is stable across both phases. Smart SmartSummary `json:"smart"` } // ThinPoolFill is the lvmthin pool fill (doc 03 §7). DataUsedFraction is the live data // fill (used/total of the lvmthin store); MetadataUsedFraction needs the privileged // `lvs` read (Phase B) and is null until then. type ThinPoolFill struct { DataUsedFraction float64 `json:"data_used_fraction"` MetadataUsedFraction *float64 `json:"metadata_used_fraction"` } // SmartSummary is a read-only disk-health summary. Health is PASSED|FAILING|UNKNOWN. // Counters are pointers so "unknown / not-applicable for this device type" (e.g. a USB // bridge that exposes no SMART, or NVMe counters on a SATA disk) is null, distinct from a // real zero. The SATA set (reallocated/pending/offline-uncorrectable) and the NVMe set // (critical_warning/media_errors/percentage_used) are both carried; a device populates // only its own set. Filled in Phase B. type SmartSummary struct { Health string `json:"health"` TemperatureC *int `json:"temperature_c"` PowerOnHours *int `json:"power_on_hours"` // SATA attributes. ReallocatedSectors *int `json:"reallocated_sectors"` PendingSectors *int `json:"pending_sectors"` OfflineUncorrectable *int `json:"offline_uncorrectable"` // NVMe attributes. CriticalWarning *int `json:"critical_warning"` MediaErrors *int `json:"media_errors"` PercentageUsed *int `json:"percentage_used"` } // SMART health constants (the reported vocabulary). const ( SmartPassed = "PASSED" SmartFailing = "FAILING" SmartUnknown = "UNKNOWN" ) // Storage target type + state constants (the reported vocabulary; doc 03 §7). const ( StorageTypeLocalDir = "local-dir" StorageTypeLVMThin = "lvmthin" StorageTypeUSB = "usb" StorageTypeNFS = "nfs" StorageTypeCIFS = "cifs" StorageTypePBS = "pbs" StorageTypeLocal = "local" // builtin dir storage (PVE "local") StorageStateAttached = "attached" StorageStateDisconnected = "disconnected" StorageStateDecommissioned = "decommissioned" ) type Backup struct{} // slice 6: per-target backup status fields TBD type RestoreTest struct{} // slice 6: self-restore-test result fields TBD type PBSSnapshot struct{} // slice 6: PBS snapshot inventory fields TBD type AuditEntry struct{} // audit-log tail entry fields TBD // ControlEnvelope is the hub's 200 response to a host-report. This slice the agent // adopts ONLY PollIntervalSeconds; the rest are reserved/forward-compat fields it // logs at most and never acts on (reconcile, slice 4, consumes them). type ControlEnvelope struct { Status string `json:"status"` // PollIntervalSeconds is a pointer so a missing field (keep current interval) is // distinguishable from an explicit 0. PollIntervalSeconds *int `json:"poll_interval_seconds"` Blocked bool `json:"blocked"` // reserved — ignored (slice 4) DesiredGeneration int64 `json:"desired_generation"` // reserved — ignored (slice 4) HasSignedOps bool `json:"has_signed_ops"` // reserved — ignored (slice 4) }