Files
felhom-agent/internal/hub/report.go
T
admin 27b68f043b v0.5.0-rc1: slice 5 Phase A — storage observe/report + watchdog (read-only, live)
Fill the slice-3 storage_targets stub and add the fast-poll storage watchdog.
Read-only this phase; the host-root surface (mounts/SMART/grow/destructive gate)
is Phase B. Hub-owned desired manifest is slice 10, so reconcile against it is
built-but-unfed.

- internal/storage: StorageTarget wire contract, durable_id derivation per type,
  HostReader seam (procfs/sysfs, root-free), Observer (storage_targets from
  ListStorage/NodeStorage + host reads, lvmthin thin-pool fill), and the watchdog
  (third daemon goroutine; debounced out-of-band report on a known target's
  attach/disconnect transition).
- proxmox.Storage: additive parse-only config fields (durable_id sources).
- collector StorageObserver seam; Loop.SetTrigger out-of-band report; daemon runs
  the watchdog as a third goroutine; StorageConfig knobs.
- cross-repo golden kept byte-identical with felhom.eu/hub; bidirectional key-set test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:59:05 +02:00

185 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"` // 0100
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
// "<host_id>/<vmid>" (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", <counters null>} — 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)
}