21b0164fad
The absent-drive alarm was generic while its recovery was specific -- a pair an operator cannot match. Mechanism now measured, not reasoned (felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md): with the device gone /disks returns 4 rows, not 3. The drive appears TWICE and the two facts the controller needs are on different rows -- the Observe row has backup_target:true but mount_path:"" and guest_path:"" (so driveTargetByPath registers NO key from it), while the registry row owns /mnt/felhom-drives/<name>, the key the gate looks up, with BackupTarget absent from its struct literal => false. WHY v0.115.0 WAS INERT: its fallback computed StablePathForRaw(t.MountPath), and in the absent state MountPath is ALSO "" -- emptied by the same exactMount failure that empties BackingDevice. It assigned nothing. Its test passed because the fixture supplied a MountPath production never supplies, and the harness left DriveTargets nil so the union loop never ran. Both corrected here; red-proof 1 replays v0.115.0's exact code against the real shape and it fails. THE JOIN, which was the hard part: with the device gone the two records share no runtime field -- no mount, no backing device, and the Observe row's DurableID has degraded off the fs-UUID. They share CONFIGURATION: storage.cfg's path on one side, the .mount unit's Where on the other, both yielding the same stable guest path. New hub.StorageTarget.ConfigPath (json:"-" -- that struct is a cross-repo contract pinned by the golden + contract_test key-set comparison, and nothing off-box needs the value), set from s.Path in observe.go, consulted in disks.go only after MountPath so the present-state path is byte-identical, plus a guest-path arm on the union dedup so exactly one row carries the drive. WHY NEITHER OBVIOUS OPTION WAS TAKEN -- both regress R-114, which shipped yesterday. backup_target_offer.go:79 reads (BackupTarget && MountPath != "") as "a real drive with its own mountpoint -- healthy" and returns before its TargetAbsent branch. Back-filling MountPath onto the Observe row (the smallest change, and the spec's lean) and teaching the registry row the flag (its MountPath is non-empty, read from the stale unit file) BOTH manufacture that row while the drive is missing, which would have told the customer the backup target is fine while its drive is gone. R-114's correctness rests on the absent-state rows not combining the flag with a mount path; that coupling was invisible until the payload existed. Pinned by TestAbsentTargetKeepsR114DegradedSignal. Role unchanged, BoundUnderParent conjunction not widened, no wire field changed. Suppressing the registry row in the absent state also removes its false state:"attached" and its root-filesystem-derived total_bytes -- R-118's symptom goes incidentally; R-118 is NOT fixed and stays open. Tests 845 -> 849, suite rc=0 read separately from this commit. Four red-proofs, each mutation asserted to have landed first. NOT live-validated at this commit: publish+vouch, C5, discrimination, over-correction.
569 lines
33 KiB
Go
569 lines
33 KiB
Go
package hub
|
||
|
||
import (
|
||
"encoding/json"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
|
||
)
|
||
|
||
// 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
|
||
|
||
// Capabilities is the agent's privileged-capability self-check snapshot (v0.44.0): per required
|
||
// `sudo -n` grant, whether it is permitted + the binary exists. The hub keys its operator alert
|
||
// on a Critical capability flipping to "degraded". Non-nil so it marshals as [].
|
||
Capabilities []capability.Status `json:"capabilities"`
|
||
|
||
// LeafFingerprint is the SHA-256 of the local-API leaf the agent CURRENTLY serves (v0.48.0). The
|
||
// hub records the first value per host as the baseline and raises `host_leaf_changed` if it ever
|
||
// changes — a proactive, fleet-wide agent-re-key alert independent of any controller's channel
|
||
// check. Empty when the local API is disabled (no leaf) → the hub treats "" as unknown, never an
|
||
// alert. Not a secret (the fp is public; the token is never reported).
|
||
LeafFingerprint string `json:"leaf_fingerprint"`
|
||
|
||
// DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe
|
||
// (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/
|
||
// sizes/coordinates, never a secret. The hub assembles it with the controller's app half.
|
||
DRRecipe *DRRecipeHostHalf `json:"dr_recipe"`
|
||
|
||
// Wireguard is the offsite-tunnel status stanza (S3, doc 06 §4.6). Present only when the
|
||
// wg_tunnel feature is enabled. The report is stored opaquely hub-side, so no hub change is
|
||
// needed; the pubkey here is the operator's revocation-recovery handle (re-add the peer with
|
||
// it). Carries NO secret — the pubkey is public by definition.
|
||
Wireguard *WireguardStatus `json:"wireguard,omitempty"`
|
||
|
||
// SelfUpdatePending is true when an operator-signed agent self-update has flipped the binary
|
||
// but the new binary has not yet committed (TASK D1). SelfUpdatePendingVersion names the
|
||
// awaited version when pending. A runs-but-never-commits binary reports pending=true every
|
||
// heartbeat → the operator sees WHY the version isn't advancing; a crash-loop is auto-rolled
|
||
// back by systemd and this flips back to false when the good binary re-commits/clears. The
|
||
// report is stored opaquely hub-side, so these additive fields need no hub-schema change.
|
||
// Both are `omitempty` (the Wireguard precedent): in the steady state (no update in flight)
|
||
// they are absent — which keeps the cross-repo host-report golden contract byte-stable without
|
||
// a hub change. They appear only while an update is pending. The hub reads an absent field as
|
||
// pending=false, the correct default.
|
||
SelfUpdatePending bool `json:"selfupdate_pending,omitempty"`
|
||
SelfUpdatePendingVersion string `json:"selfupdate_pending_version,omitempty"`
|
||
|
||
// MgmtPlane is the management-plane health stanza (TASK G1). It reports whether OpenSSH's SHARED
|
||
// privilege-separation dir (/run/sshd) is present and whether the stock sshd listener answers, plus
|
||
// whether the agent-independent watchdog auto-healed a missing dir since boot (and when). This is
|
||
// the visibility half of the break-glass system: the dumb watchdog fixes /run/sshd with no login,
|
||
// and this stanza surfaces a RECURRING clobber to the operator BEFORE it becomes a lockout —
|
||
// complementing host_staleness (which only catches a box gone silent). `omitempty` (the
|
||
// SelfUpdatePending precedent): stored opaquely hub-side, so these additive fields need no
|
||
// hub-schema change and are absent when the reporter is not wired.
|
||
MgmtPlane *MgmtPlaneStatus `json:"mgmt_plane,omitempty"`
|
||
|
||
// PBSDR is the PBS-DR-tier bridge status stanza (slice 2). Present only when the pbsdr
|
||
// consumer is wired. `consumed_failed` is the LOUD persistent state: the one-time token
|
||
// secret was consumed but the apply failed afterwards — the secret is burned, the bridge
|
||
// will NOT silently retry, the operator must Re-issue on the hub. Stored opaquely hub-side
|
||
// (the Wireguard precedent) — additive, no hub-schema change; hub rendering joins in slice 3.
|
||
// Carries NO secret.
|
||
PBSDR *PBSDRStatus `json:"pbs_dr,omitempty"`
|
||
|
||
// GuestNet is the per-guest network-watchdog stanza (R-54). Present only when the guestnet
|
||
// watchdog is wired. Same additive/opaque contract as PBSDR and Wireguard above — no
|
||
// hub-schema change, absent when the reporter is not wired. Carries NO secret: addresses,
|
||
// route/liveness booleans, heal timestamps and counters only.
|
||
//
|
||
// NAMING NOTE (deliberate deviation from TASK-D, which called this block `WireGuestNet`):
|
||
// in this repo `Wire*` types are the DOWN direction (WireDesiredState/WirePBSDR — what the
|
||
// hub sends the agent), while UP-direction report stanzas are `*Status`. A `WireGuestNet`
|
||
// on HostReport would have been the only report block named against that convention.
|
||
GuestNet *GuestNetStatus `json:"guest_net,omitempty"`
|
||
|
||
// LogTail is the agent's on-demand debug-ring tail (v0.83.0 observability) — the agent
|
||
// mirror of the controller's report log_tails channel. Present ONLY on the heartbeat
|
||
// right after the control envelope requested it (log_tail_requested); consume-once on
|
||
// both ends (the hub clears its pending request on arrival). Newest lines kept, byte-
|
||
// capped loop-side. Carries log lines only — the logging conventions forbid secrets in
|
||
// any log line, and the hub's bundle gate re-checks before storing. `omitempty`: absent
|
||
// in the steady state, so the cross-repo host-report golden stays byte-stable.
|
||
LogTail *LogTail `json:"log_tail,omitempty"`
|
||
|
||
// OOB is the operator-access health stanza (TASK H1). It answers the operator's question — "can I
|
||
// get into this box right now, and if not, why" — from the hub: felhom-sshd up + on which port,
|
||
// locally reachable, the tunnel handshake age (the OOB path rides wg-felhom), whether the operator
|
||
// peer/key is configured, and whether the config is invalid. The heartbeat reaches the hub over
|
||
// HTTPS even when felhom-sshd or the tunnel is DOWN (channel independence). `omitempty`: absent
|
||
// when the feature is not wired (pre-H1) — additive, no hub-schema change.
|
||
OOB *OOBStatus `json:"oob,omitempty"`
|
||
}
|
||
|
||
// PBSDRStatus is the per-heartbeat PBS-DR-tier bridge state (slice 2). States:
|
||
// "adopted" (existing entry verified + reconciled, no consume), "applied" (fresh entry created,
|
||
// K born), "waiting_secret" (verified but no unconsumed secret staged — retrying),
|
||
// "verify_failed" (fingerprint/reachability pre-consume check failing — retrying, NOTHING
|
||
// consumed), "consumed_failed" (LOUD: secret burned, apply failed, no auto-retry — operator
|
||
// re-issue required), "disabled" (descriptor enabled:false). Carries no secret.
|
||
// GuestNetStatus is the R-54 guest-network watchdog stanza. `guests` carries one entry per owned
|
||
// RUNNING guest that has been probed at least once; an empty list with a fresh `checked_at` means
|
||
// the watchdog ran and found nothing to report, which is deliberately distinguishable from the
|
||
// stanza being absent (= the watchdog is not wired at all).
|
||
type GuestNetStatus struct {
|
||
CheckedAt string `json:"checked_at"` // RFC3339, the sweep this snapshot came from
|
||
Guests []GuestNetGuest `json:"guests,omitempty"`
|
||
}
|
||
|
||
// GuestNetGuest mirrors guestnet.GuestReport on the wire. The two structs are deliberately separate:
|
||
// internal/hub owns the wire contract and imports no feature package (the consumer-side seam rule).
|
||
type GuestNetGuest struct {
|
||
VMID int `json:"vmid"`
|
||
State string `json:"state"` // healthy | unhealthy | static_fault | unknown
|
||
Mode string `json:"mode"` // dhcp | static | unknown
|
||
IP string `json:"ip,omitempty"`
|
||
HasRoute bool `json:"has_route"`
|
||
DHClientAlive bool `json:"dhclient_alive"`
|
||
CheckedAt string `json:"checked_at,omitempty"`
|
||
Healed bool `json:"healed,omitempty"`
|
||
HealSucceeded bool `json:"heal_succeeded,omitempty"`
|
||
LastHealAt string `json:"last_heal_at,omitempty"`
|
||
HealsLastHour int `json:"heals_last_hour,omitempty"`
|
||
Damped bool `json:"damped,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
type PBSDRStatus struct {
|
||
State string `json:"state"`
|
||
StorageID string `json:"storage_id,omitempty"`
|
||
Namespace string `json:"namespace,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
ConsumedFailed bool `json:"consumed_failed,omitempty"`
|
||
AppliedAt string `json:"applied_at,omitempty"` // RFC3339; set on adopted/applied
|
||
// AuthFailed (R-39, v0.91.0) — the credential this box holds is REJECTED by PBS (401). Set by the
|
||
// verify loop's ProbeAuth, which before v0.91.0 could not run at all: the loop read the secret
|
||
// file directly as non-root and always failed with "permission denied", so an applied-and-dead
|
||
// tier was invisible to both tiers. The hub's pbsdrheal escalates state="auth_failed" to a fresh
|
||
// mint.
|
||
AuthFailed bool `json:"auth_failed,omitempty"`
|
||
}
|
||
|
||
// OOBStatus is the per-heartbeat operator-access health (TASK H1). Carries no secret.
|
||
type OOBStatus struct {
|
||
FelhomSshdActive bool `json:"felhom_sshd_active"` // the felhom-sshd unit is active
|
||
FelhomSshdPort int `json:"felhom_sshd_port"` // the claimed OOB port (0 = unclaimed)
|
||
Reachable bool `json:"reachable"` // a local TCP dial to the OOB port succeeds
|
||
ConfigInvalid bool `json:"config_invalid"` // `sshd -t` on the OOB config fails
|
||
OperatorPeerConfigured bool `json:"operator_peer_configured"` // oob_peer_ip present in desired-state
|
||
OperatorKeyConfigured bool `json:"operator_key_configured"` // operator authorized_key installed
|
||
WGHandshakeAgeS *int64 `json:"wg_handshake_age_s,omitempty"` // wg-felhom last handshake age (nil = unknown)
|
||
HealedAt string `json:"healed_at,omitempty"` // last felhom-sshd auto-heal (RFC3339)
|
||
}
|
||
|
||
// MgmtPlaneStatus is the per-heartbeat management-plane health (TASK G1). Carries no secret.
|
||
// HealedRecently is true while the watchdog's heal-marker exists (a privsep-dir heal happened this
|
||
// boot); PrivsepHealedAt is that marker's RFC3339 timestamp (absent when no heal has occurred). The
|
||
// hub raises a warning event on a PrivsepHealedAt it has not alerted on — a recurring auto-heal means
|
||
// a persistent clobber cause worth investigating before the box locks out.
|
||
type MgmtPlaneStatus struct {
|
||
PrivsepDirOK bool `json:"privsep_dir_ok"` // /run/sshd exists (the KEXINIT-reset detector)
|
||
SshdReachable bool `json:"sshd_reachable"` // the stock sshd listener accepts TCP
|
||
HealedRecently bool `json:"healed_recently"` // the watchdog heal-marker is present (this boot)
|
||
PrivsepHealedAt string `json:"privsep_healed_at,omitempty"` // marker timestamp; hub warns on a NEW value
|
||
}
|
||
|
||
// WireguardStatus is the per-heartbeat offsite-tunnel status (S3). LastHandshakeAgeS is nil when
|
||
// the handshake age is unreadable (service down, capability degraded) — nil ≠ 0.
|
||
type WireguardStatus struct {
|
||
Pubkey string `json:"pubkey"`
|
||
Registered bool `json:"registered"` // the registration marker exists
|
||
Active bool `json:"active"` // wg-quick@wg-felhom is-active
|
||
LastHandshakeAgeS *int64 `json:"last_handshake_age_s,omitempty"`
|
||
AssignedIP string `json:"assigned_ip,omitempty"` // from the marker, e.g. "10.77.0.2/32"
|
||
}
|
||
|
||
// 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"`
|
||
// CPUTempC is the host CPU/chassis temperature in whole °C, or null when the hardware
|
||
// exposes no usable sensor (a headless VM, an unsupported board, or any read error all
|
||
// degrade to null — a missing sensor never fails the report). Same nullable contract as
|
||
// the per-disk SmartSummary.TemperatureC. Sourced from sysfs (hwmon / thermal zones).
|
||
// Cross-repo wire field (slice 9) — the hub's HostMetrics copy + golden carry it too.
|
||
CPUTempC *int `json:"cpu_temp_c"`
|
||
// WrapperSHA256 is the sha256 of the installed PBS-DR apply wrapper
|
||
// (/usr/local/sbin/felhom-pbs-apply), R-50b(a), v0.91.0.
|
||
//
|
||
// That wrapper is root-owned 0755 and the pinned sudoers vector for the PBS storage verbs, yet it
|
||
// is installed from `raw/branch/main` — unversioned, unpinned and absent from the Day-0 artifact
|
||
// manifest. So "which wrapper is on this host?" had no answer: two hosts installed a week apart
|
||
// could carry different privileged code while reporting the same agent version. Reporting the hash
|
||
// does not fix the delivery channel (R-50b(b)/(c)); it makes drift VISIBLE.
|
||
//
|
||
// Empty = unreadable/absent, which the hub treats as UNKNOWN, never as drift.
|
||
WrapperSHA256 string `json:"wrapper_sha256,omitempty"`
|
||
}
|
||
|
||
// 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
|
||
// ConfigPath is the storage's CONFIGURED path from storage.cfg (proxmox.Storage.Path) — not a
|
||
// resolved mount. It is the only identity a dir storage keeps when its device is gone: MountPath
|
||
// and BackingDevice both empty out (observe.go's exactMount block) and DurableID degrades off the
|
||
// fs-UUID, so the configured path is what still says WHICH drive this row is about (R-116).
|
||
//
|
||
// `json:"-"` DELIBERATELY. This struct is a cross-repo contract duplicated in felhom.eu/hub and
|
||
// pinned by testdata/host-report.golden.json + contract_test.go's key-set comparison; a wire-visible
|
||
// field here would need a matching change in the other repo to stay non-drifting. Nothing off-box
|
||
// needs this value — its only consumer is the agent's own /disks construction, in-process.
|
||
ConfigPath string `json:"-"`
|
||
// 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"`
|
||
|
||
// ModelName is smartctl's own device model (v0.95.0), captured from the JSON already parsed, so
|
||
// the UI can show a human label ("TOSHIBA MQ04ABF100") instead of a raw UUID. omitempty +
|
||
// pointer: absent on an old agent or a device that reports no model.
|
||
ModelName *string `json:"model_name,omitempty"`
|
||
|
||
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"
|
||
)
|
||
|
||
// Backup is the latest guest-vzdump result per target (doc 03 §8, slice 6 Phase A). An
|
||
// agent-initiated vzdump is CRASH-CONSISTENT only (no fsfreeze; app-consistency needs the
|
||
// controller quiesce, slice 8) — marked so here. UncoveredVolumes lists the guest's
|
||
// backup=0 (or backup-unset) mountpoints excluded from the vzdump — the bulk-volume DR gap
|
||
// (the bulk-backup mechanism is slice 10). Cross-repo contract: keep byte-identical with
|
||
// felhom.eu/hub and the bidirectional golden key-set test.
|
||
type Backup struct {
|
||
TargetID string `json:"target_id"` // backup storage name (e.g. "local")
|
||
VMID int `json:"vmid"` // source guest
|
||
Archive string `json:"archive"` // produced vzdump volid
|
||
Mode string `json:"mode"` // snapshot | stop
|
||
CrashConsistent bool `json:"crash_consistent"` // always true this slice
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
Success bool `json:"success"`
|
||
Error string `json:"error,omitempty"`
|
||
StartedAt string `json:"started_at"` // RFC3339
|
||
DurationSeconds float64 `json:"duration_seconds"`
|
||
UncoveredVolumes []string `json:"uncovered_volumes"` // backup=0/unset mountpoints (bulk gap)
|
||
}
|
||
|
||
// RestoreTest is the latest self-restore-test result (doc 03 §8). This slice verifies
|
||
// boot+running only (deep app-health is slice 8). SourceTier is "local" here; PBS is Phase B.
|
||
type RestoreTest struct {
|
||
SourceArchive string `json:"source_archive"`
|
||
SourceTier string `json:"source_tier"` // "local" (pbs = Phase B)
|
||
ScratchVMID int `json:"scratch_vmid"`
|
||
Pass bool `json:"pass"`
|
||
Verified string `json:"verified"` // "boot+running" this slice
|
||
Error string `json:"error,omitempty"`
|
||
TestedAt string `json:"tested_at"` // RFC3339
|
||
DurationSeconds float64 `json:"duration_seconds"`
|
||
// Warnings are the guest-start task's warning line(s) (e.g. the systemd-nesting advisory).
|
||
// Present on a PASS that emitted warnings; pass/fail itself is liveness-only, so a passed
|
||
// restore-test can carry warnings. Omitted when there are none.
|
||
Warnings []string `json:"warnings,omitempty"`
|
||
// WarningsRecognized is true iff every Warnings line is the known-benign anchor. Omitted
|
||
// (⇒ false) when absent — and false is the SAFE default: the hub then treats it as an
|
||
// unrecognized warning (louder), so a missing flag can only over-notice, never hide.
|
||
WarningsRecognized bool `json:"warnings_recognized,omitempty"`
|
||
// MountParity ("ok"|"mismatch"; omitted on pre-v0.76.0 agents) + MountInventory (the verified
|
||
// mpN set) carry the GL-5b full-fidelity proof: the restored scratch matched the ARCHIVE's
|
||
// mount layout, not just booted. Additive — a hub that predates them ignores the unknown keys.
|
||
MountParity string `json:"mount_parity,omitempty"`
|
||
MountInventory []string `json:"mount_inventory,omitempty"`
|
||
}
|
||
|
||
// PBSSnapshot is one PBS (offsite) snapshot's inventory + integrity state (doc 03 §8, slice
|
||
// 6 Phase B). Sourced from the PBS API (internal/pbs). `verify_state` is the load-bearing
|
||
// field — "none" until a verify runs, then "ok"/"failed" (a failed verify is the loudest
|
||
// offsite-DR signal). `encrypted` is derived from the snapshot's data crypt-mode
|
||
// (zero-knowledge: the PBS server can't read it). Cross-repo contract — byte-identical golden
|
||
// + bidirectional key-set test, the slice-5/6 pattern.
|
||
type PBSSnapshot struct {
|
||
Namespace string `json:"namespace"` // "root" = default ns
|
||
BackupType string `json:"backup_type"` // ct | vm
|
||
BackupID string `json:"backup_id"`
|
||
BackupTime string `json:"backup_time"` // RFC3339
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
Owner string `json:"owner"`
|
||
Protected bool `json:"protected"`
|
||
Encrypted bool `json:"encrypted"`
|
||
VerifyState string `json:"verify_state"` // ok | failed | none
|
||
VerifyUPID string `json:"verify_upid,omitempty"`
|
||
}
|
||
|
||
type AuditEntry struct{} // audit-log tail entry fields TBD
|
||
|
||
// ControlEnvelope is the hub's 200 response to a host-report — the "Down" channel (slice 10A).
|
||
// It is a cheap change-notification on every heartbeat: the agent adopts PollIntervalSeconds,
|
||
// and when DesiredGeneration ADVANCES past its cached one it fetches the full desired-state from
|
||
// GET /hosts/{id}/desired-state (the heavy state moves only on change). HasSignedOps flags a
|
||
// non-empty signed-jobs queue (the agent fetches/executes them in 10B). Blocked stays reserved.
|
||
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
|
||
DesiredGeneration int64 `json:"desired_generation"` // slice 10A: the cached-vs-current change signal
|
||
HasSignedOps bool `json:"has_signed_ops"` // slice 10A: signed-jobs queue non-empty (exec 10B)
|
||
// LogTailRequested (v0.83.0) — the operator wants this agent's debug-ring tail; the
|
||
// NEXT heartbeat carries it in log_tail (the report-channel log_tail_requests mirror).
|
||
// Absent/false on an old hub → nothing happens.
|
||
LogTailRequested bool `json:"log_tail_requested"`
|
||
}
|
||
|
||
// LogTail is the heartbeat's on-demand agent log tail (see HostReport.LogTail).
|
||
type LogTail struct {
|
||
CollectedAt string `json:"collected_at"` // RFC3339
|
||
Lines []string `json:"lines"`
|
||
}
|
||
|
||
// DesiredStateResponse is GET /hosts/{host_id}/desired-state (slice 10A — the "Down" channel's
|
||
// heavy payload, fetched only when the envelope's generation advances). Generation is the
|
||
// generation this state corresponds to, so the agent caches state+generation atomically. This is
|
||
// a cross-repo wire contract (DUPLICATED in felhom.eu/hub until a shared module exists); the
|
||
// desired-state golden stays byte-identical across the two repos.
|
||
type DesiredStateResponse struct {
|
||
Generation int64 `json:"generation"`
|
||
DesiredState WireDesiredState `json:"desired_state"`
|
||
}
|
||
|
||
// WireDesiredState is the hub's authoritative per-host target (slice 10A). The agent reconciles the
|
||
// parts it can today (guests: benign deltas reconciled, an explicit decommission gated
|
||
// pending_signature); the rest are FORWARD-COMPAT — carried + cached, NOT acted on in 10A. The
|
||
// restore_directive is consumed in 10D (host/guest-loss DR); storage_manifest / backup_policy /
|
||
// pbs_namespace are placeholders kept opaque so the wire is stable as those land. The wireguard
|
||
// block (S3) is HUB-OWNED state merged into the served document at read time (hub S2) — consumed
|
||
// by internal/wgtunnel via the desired.Syncer raw-consumer seam.
|
||
type WireDesiredState struct {
|
||
Guests []WireDesiredGuest `json:"guests"`
|
||
|
||
StorageManifest json.RawMessage `json:"storage_manifest,omitempty"`
|
||
BackupPolicy json.RawMessage `json:"backup_policy,omitempty"`
|
||
PBSNamespace string `json:"pbs_namespace,omitempty"`
|
||
RestoreDirective *WireRestoreDirective `json:"restore_directive,omitempty"` // slice 10D (forward-compat)
|
||
Wireguard *WireWireguard `json:"wireguard,omitempty"` // S3 (doc 06 §3.2; golden-pinned)
|
||
PBSDR *WirePBSDR `json:"pbs_dr,omitempty"` // PBS DR tier (slice 2 consumer)
|
||
}
|
||
|
||
// WirePBSDR is the hub's PBS-DR-tier descriptor (PBS DR slice 1, hub/internal/web/pbsdr.go
|
||
// pbsDRDescriptor — field-exact, cross-repo). NON-SECRET by contract: the token secret NEVER
|
||
// rides the desired-state; the agent fetches it consume-once via ConsumePBSToken. Absent/nil on
|
||
// pre-v0.44.0 hubs → the pbsdr consumer no-ops (old-hub compat).
|
||
type WirePBSDR struct {
|
||
Enabled bool `json:"enabled"`
|
||
StorageID string `json:"storage_id,omitempty"`
|
||
PBSTunnelIP string `json:"pbs_tunnel_ip,omitempty"`
|
||
Datastore string `json:"datastore,omitempty"`
|
||
Namespace string `json:"namespace,omitempty"`
|
||
TokenID string `json:"token_id,omitempty"`
|
||
Fingerprint string `json:"fingerprint,omitempty"`
|
||
// SecretGeneration (R-39, agent v0.91.0 / hub v0.68.0) is the hub's monotonic per-host counter,
|
||
// advanced by every fresh secret MINT. It carries no secret material — only the fact that one
|
||
// rotated.
|
||
//
|
||
// THIS FIELD IS THE RE-ARM SIGNAL, and it works only because descriptorHash marshals THIS STRUCT:
|
||
// an ep0 re-issue re-keys the secret of an existing token, so token_id, fingerprint, datastore and
|
||
// namespace all come back byte-identical. Without this field the descriptor never moves, the
|
||
// converged agent short-circuits, the fresh secret is never consumed, and the box serves a revoked
|
||
// credential while reporting `applied` (the N100, 2026-07-18).
|
||
//
|
||
// Corollary worth stating: an agent that does NOT carry this field drops the unknown JSON key and
|
||
// keeps today's behaviour exactly — inert, not broken. That is why hub v0.68.0 is safe to deploy
|
||
// ahead of the fleet, and why the re-arm guarantee needs agent >= 0.91.0.
|
||
SecretGeneration int64 `json:"secret_generation,omitempty"`
|
||
}
|
||
|
||
// WireWireguard is the hub-owned offsite-tunnel assignment (S3) — field-exact with the S2 golden
|
||
// (testdata/desired-state-wireguard.golden.json, byte-identical hub copy). Client-side
|
||
// AllowedIPs, PersistentKeepalive=25 and MTU 1280 are deliberately NOT wire fields — wgtunnel
|
||
// constants derived from endpoint.pbs_tunnel_ip + doc 06 §4.
|
||
type WireWireguard struct {
|
||
Endpoint WireWireguardEndpoint `json:"endpoint"`
|
||
Pubkey string `json:"pubkey"` // the box's registered pubkey
|
||
AssignedIP string `json:"assigned_ip"` // e.g. "10.77.0.2/32"
|
||
|
||
// OOBPeerIP is the operator OOB peer's tunnel address (bare IPv4, e.g. "10.77.0.250", TASK H1).
|
||
// When set, the agent appends it as a second /32 to the box's wg-felhom AllowedIPs so packets the
|
||
// endpoint forwards FROM the operator are accepted — RENDERED into the conf (not a runtime `wg
|
||
// set`) so it survives self-heal/restart/reboot ([OF-1]: a runtime widening was wiped by the
|
||
// agent's own self-heal). `omitempty`: absent = OOB off, byte-identical conf for pre-H1 hosts.
|
||
OOBPeerIP string `json:"oob_peer_ip,omitempty"`
|
||
|
||
// OOBOperatorSSHKey is the operator's SSH PUBLIC key (an authorized_keys line, TASK H1). The agent
|
||
// writes it to felhom-sshd's dedicated AuthorizedKeysFile (/etc/felhom-sshd/authorized_keys/{root,
|
||
// felhom-op}) — OUTSIDE ~/.ssh, so the customer's sshd never honours it [SF-3]. Hub-driven so it
|
||
// rotates fleet-wide. `omitempty`: absent = no operator login installed. NOT a secret (public key).
|
||
OOBOperatorSSHKey string `json:"oob_operator_ssh_key,omitempty"`
|
||
}
|
||
|
||
// WireWireguardEndpoint is the endpoint half of the wireguard block.
|
||
type WireWireguardEndpoint struct {
|
||
DNSName string `json:"dns_name"`
|
||
WGPort int `json:"wg_port"`
|
||
ServerPubkey string `json:"server_pubkey"`
|
||
PBSTunnelIP string `json:"pbs_tunnel_ip"`
|
||
}
|
||
|
||
// WireDesiredGuest is one guest's target (slice 10A). Every field is optional ("unmanaged"); the
|
||
// agent's planner acts only on the fields that are set. Run is running|stopped|""; Spec reuses
|
||
// GuestSpec (cores/memory_bytes/disk_bytes); Decommission is the EXPLICIT destructive delta (gated
|
||
// pending_signature in 10A — executor is 10B).
|
||
type WireDesiredGuest struct {
|
||
VMID int `json:"vmid"`
|
||
Run string `json:"run,omitempty"`
|
||
Spec *GuestSpec `json:"spec,omitempty"`
|
||
Description *string `json:"description,omitempty"`
|
||
Decommission bool `json:"decommission,omitempty"`
|
||
}
|
||
|
||
// WireRestoreDirective is the forward-compat restore directive (slice 10D — host/guest-loss DR).
|
||
// Defined now so the wire contract is stable; 10A carries it through to the cache but does NOT
|
||
// consume it (no restore is initiated from desired-state in 10A).
|
||
type WireRestoreDirective struct {
|
||
Mode string `json:"mode,omitempty"` // guest_loss | host_loss (10D vocabulary)
|
||
Archive string `json:"archive,omitempty"` // source archive/snapshot to restore from
|
||
VMID int `json:"vmid,omitempty"`
|
||
}
|