slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)

The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:59 +02:00
parent aa4dfb75ea
commit 8ecf8929fb
19 changed files with 836 additions and 59 deletions
+46 -38
View File
@@ -1,53 +1,61 @@
# REPORT — slice 9 (agent half): host metrics to the controller (v0.14.0) (2026-06-10)
# REPORT — slice 10A (agent half): hub desired-state serving — the "Down" channel (v0.15.0) (2026-06-10)
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
## What was implemented
The agent half of **slice 9** — re-serving the host's health to the customer's controller, plus the
one new collector (CPU/chassis temperature). The de-privileged controller (slice 8C) sees only its
own cgroup, so it cannot read host metrics itself; the agent already collects host CPU/mem/loadavg/
uptime + per-storage targets for the hub, and slice 9 exposes that to the customer over the local API.
The agent half of **slice 10A**: activate the control envelope as the live **Down channel** and feed
a hub-backed desired-state into the reconcile engine. Pairs with hub v0.9.0.
### `internal/hub/cputemp.go` — CPU/chassis-temp collector (new)
- `TempReader` seam + `SysfsTempReader`: reads the CPU package temperature from **sysfs** — hwmon
(`coretemp`/`k10temp`/`zenpower`/`cpu_thermal`, preferring the `Package id 0` input) then thermal
zones (preferring `x86_pkg_temp`/`coretemp`/`cpu-thermal`, falling back to `acpitz`).
- **No external binary, no privilege** (sysfs is world-readable) → the root-CLI fence is untouched.
- **Graceful-null**: a missing sensor, an unsupported board, an implausible reading (outside
5150 °C), or any read error all degrade to `nil` ("n/a") — never fails the report. Same nullable
contract as the per-disk `SmartSummary.TemperatureC`.
### The control loop (now live)
report (heartbeat) → control envelope → (DesiredGeneration advanced past cache? fetch desired-state)
→ reconcile benign / gate destructive → report. The heartbeat stays light; the heavy desired-state is
fetched **only on a generation advance**.
### `internal/hub` — shared wire field + collector reuse
- `HostMetrics` gains **`CPUTempC *int` (`cpu_temp_c`)** — nullable, on the **shared** struct, so the
**hub report carries it too** (operator freebie). Cross-repo host-report golden updated
**byte-identical** with the hub's copy.
- `Collector` gains a nil-safe `temp TempReader` (defaults to the real `SysfsTempReader`;
`SetTempReader` injects a fake in tests). `Collect()` now sets `cpu_temp_c` on the report.
- **`Collector.HostMetricsNow(ctx)`** — a fresh `NodeStatus` + CPU-temp read returning just the host
block; the source for the local API (current cpu%/temp, not the 15-min hub snapshot).
### `internal/reconcile`
- **`DesiredGuest.Decommission`** — the canonical **destructive** desired-state delta (an EXPLICIT
flag, never "absent from the list", so a partial hub list can't mass-destroy). Planner emits
`ActionDecommission``ClassDecommission` → Destructive → the gate refuses `pending_signature`.
- **`Reconcile`** now counts a `pending_signature` gate refusal as **`Result.Pending`** (expected,
INFO-logged), not a failure; any other refusal stays a real failure. `ActionDecommission` has **no
executor** (10B) — a defensive guard refuses to run it.
- **`CachingProvider`** — thread-safe DesiredState + generation cache (`Desired`/`Update`/
`Generation`); the production provider, replacing `EmptyProvider` in the daemon engine. Empty until
the hub serves intent → cold-start is a live no-op (unchanged behaviour).
### `internal/localapi` — `GET /host/metrics` (new endpoint)
- `host_metrics.go`: host-wide health (cpu%/mem/load/uptime/`cpu_temp_c`) + per-storage capacity
(total/used/fraction, thin-pool, SMART temp+wear). Token-authed via `withGuest` (host-wide data; a
cross-guest `?vmid=` still 403). Best-effort on storage (a view error still returns the host
block). Served only when the `HostMetrics` provider (the shared collector) is wired in
`buildLocalAPIServer` — else 503 "not configured".
### `internal/hub`
- `ControlEnvelope` fields are now active. New wire types **`DesiredStateResponse`** +
**`WireDesiredState`** (guests + forward-compat `restore_directive` (10D) / `pbs_namespace` / opaque
`storage_manifest`+`backup_policy`) + **`WireDesiredGuest`**. New **`Client.FetchDesiredState`**
(GET `/api/v1/hosts/{host_id}/desired-state`, self-scoped to the client's own host). New
**`EnvelopeObserver`** loop seam + `SetEnvelopeObserver` (hub does not import reconcile/desired).
### `internal/desired` (new) + wiring
- **`Syncer`** — implements `hub.EnvelopeObserver`; fetches on a generation advance, maps wire→domain,
updates the `CachingProvider`. Caches the **fetched** generation (race-robust); a fetch failure keeps
the last-known state. `restore_directive` carried + logged, not acted on (10D). Wired in
`cmd/felhom-agent`: provider → engine, syncer → loop.
## Tests (all green)
- `cputemp_test.go`, `hostmetrics_test.go`, `host_metrics_test.go`: hwmon/thermal-zone selection +
**graceful-null**, `HostMetricsNow` populate/null/hard-error, endpoint populated + `cpu_temp_c:null`
serialization + **401 without a token** + 403 cross-guest + 503 not-configured.
- `go test ./...` green; `go vet ./internal/hub ./internal/localapi` clean.
- reconcile: **benign applied + destructive decommission gated pending (not executed)**; Plan
decommission-only + classifies Destructive; CachingProvider update/isolation.
- desired: **fetch-once-on-advance** / no-refetch-on-unchanged / fetch-failure-keeps-cache /
caches-the-fetched-generation.
- hub: `FetchDesiredState` path+auth+decode (incl. `restore_directive`) + typed 403; loop notifies the
observer + adopts `PollIntervalSeconds`, skips the observer on a report error.
- cross-repo golden (`desired-state` + `control-envelope`) decode + key-set guard, byte-identical with
felhom.eu/hub. `go test ./...` green.
## Versioning / docs
- Version `0.13.0 → 0.14.0`; `CHANGELOG.md` updated. Doc 03 §6 (local-API surface) + §9 (roadmap +
changelog) updated.
- Version `0.14.0 → 0.15.0`; `CHANGELOG.md` updated. Doc 03 §4 (control loop live) + §9 (slice table:
10A done, 10B/10C/10D pending) updated.
## Assumption (noted, not built)
- **One customer per host** (home-server model): `/host/metrics` is host-wide. A multi-customer host
would leak cross-customer CPU/mem → revisit then.
## Out of scope (per the task)
- Signed-op **execution** (verify + run the gated destructive op) → 10B (10A marks it pending only).
- **Restore-mode / re-enroll** consumption (a new box's first directive) → 10D; 10A serves
already-authenticated hosts only.
## Pending
- **Live validation** on the demo (build + deploy agent v0.14.0; controller monitoring page → real
N100 CPU%/temp + per-storage, cross-checked vs `pvesh`/`free`/`df`).
- **Live validation** on the demo: build+deploy agent v0.15.0 + hub v0.9.0; admin-set a desired-state
with a benign + a decommission delta → generation bumps → agent fetches → reconciles benign + gates
the decommission; change `poll_interval_seconds`; confirm a host can't fetch another host's state.