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:
@@ -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
|
||||
5–150 °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.
|
||||
|
||||
Reference in New Issue
Block a user