agent v0.86.0: DR-tier-by-default — capability inactive state (GatedBy/GateActive, pbsdr gate via DRConfigured) + F-3 root-run provision parent ownership

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 20:06:27 +02:00
parent bcb8dad2aa
commit c20814e6c2
11 changed files with 403 additions and 148 deletions
+29
View File
@@ -1,3 +1,32 @@
## v0.86.0 — DR-tier-by-default: capability `inactive` state + F-3 provision-parent ownership (2026-07-12)
Agent half of the DR-tier-by-default batch (DRILL-day0-vm-2026-07-12; operator decisions: DR
capability is BAKED on every install, activation is a hub flag, disabled ≠ degraded).
- **Capability `inactive` state** (`internal/capability`): a third status next to ok/degraded —
a config-GATED capability whose plumbing is HEALTHY (binary present, sudo granted) but whose
feature is off reports `inactive` / reason `disabled by configuration`. The 3 `pbsdr-*` entries
are gated (`GatedBy=GatePBSDR`, applied by the stable name prefix in `Manifest()`); broken
plumbing (binary missing / grant denied) stays DEGRADED even with the gate off — an un-migrated
pre-v1.15.0 box must never look deliberately disabled. `Summarize` counts only real degraded
(inactive never error-logs); the startup self-check logs an `inactive` count and now runs AFTER
the pbsdr gate wiring so its snapshot matches the first report.
- **`pbsdr.Manager.DRConfigured()`** — the gate's answer: true when the last-seen descriptor was
enabled (any live state except `disabled`); before the first desired-state fetch it falls back
to the persisted converged marker, so an applied box never flaps to inactive across a restart.
- **F-3 — provision parent-dir ownership** (`internal/provision/backhalf.go`): a ROOT-run
provision (the Day-0 one-shot) now chowns the just-created `guests/` + `guests/<vmid>/` PARENT
dirs to the state-dir's owner (`chown --reference`, NON-recursive — the bootstrap leaf stays
the mapped guest-root's). Previously they were left root:root 0700 → the non-root daemon's
lanresolver got "permission denied" (drill live-fix now also applied to felhom-pve, which had
the same latent state; Peti's host unreachable — deferred). A daemon-run (non-root) provision
skips it (`geteuid` seam).
- Tests + red-proofs: gate-off-healthy→inactive / gate-off-broken→degraded / gate-on→ok /
exactly-pbsdr-gated; DRConfigured lifecycle (incl. marker-across-restart + disabled-wins);
root-run parent chown issued, non-root not, never recursive. All three mutations proven red.
- Shipping note: `configs/felhom-pbs-apply` already lives in this repo — host-install v1.15.0
(felhom.eu) now ships it like the mkfs/selfupdate wrappers (drill F-7); no publish change here.
## v0.85.0 — the boot/recovery plane: F12 ordering-cycle fix + F11/F10/F9/F2/F1 + appliance self-heal (2026-07-12) ## v0.85.0 — the boot/recovery plane: F12 ordering-cycle fix + F11/F10/F9/F2/F1 + appliance self-heal (2026-07-12)
Fixes the findings CAMPAIGN-3 (`felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`) raised Fixes the findings CAMPAIGN-3 (`felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`) raised
+38 -75
View File
@@ -1,85 +1,48 @@
# REPORT — agent v0.85.0: the boot/recovery plane + appliance self-heal (CAMPAIGN-3 Task A) # REPORT — agent v0.86.0: DR-tier-by-default (capability `inactive` + F-3 parent ownership)
**Date:** 2026-07-12 · **Baselines:** agent v0.84.0 (`bec4bac`) → **v0.85.0**; felhom.eu post-`f97f7803`. **Date:** 2026-07-12 · **Baselines:** agent v0.85.0 (`bcb8dad`) → **v0.86.0**.
**Source of truth:** `felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`. **Source of truth:** the DR-tier-by-default batch spec + `felhom.eu/documentation/audits/DRILL-day0-vm-2026-07-12.md`.
**Scope:** F12 (CRITICAL), F11/F10/F9 (HIGH/CRITICAL), F2/F1 (LOW), the guest-hook rc255 belt, and the **Scope:** agent half of the batch — the capability `inactive` state (disabled ≠ degraded) and the
operator-mandated appliance-mode node self-heal. Controller untouched (MinAgent unchanged). Green gate F-3 provision parent-dir ownership fix. No escrow/ceremony changes (decision 4: identity-only
(`go build ./... && go vet ./... && go test ./...`) PASS on the build server. escrow is PARKED by policy). Green gate: `go build ./... && go vet ./... && go test ./...` — all
26 packages ok.
## What shipped ## What shipped
- **F12 (CRITICAL) — boot ordering cycle.** Both rendered units drop `After=/Wants=network-online.target` 1. **Capability `inactive` state** (`internal/capability/probe.go`, `manifest.go`):
(`.mount` keeps `_netdev`; `.automount` gets no network relation). `MigrateNetworkUnits` — a general - `StatusInactive = "inactive"`, `ReasonInactive = "disabled by configuration"`.
template-drift reconcile (SHA-256 content compare vs a fresh render of the reconstructed spec; - `Capability.GatedBy` + `Prober.GateActive(gate)` — a HEALTHY probe of a gated capability
rewrite + one batched `daemon-reload`; idempotent) — repairs ALREADY-INSTALLED units at startup and whose gate is off is downgraded ok→inactive. Degraded (binary missing / sudo denied) is
at the head of `EnsureNetworkMount`. NEVER downgraded: an un-migrated pre-v1.15.0 box must not masquerade as deliberately off.
- **F11 — read the right unit** (`netReassertClassify`): decision by host `/proc/mounts` fstype only, - The 3 `pbsdr-*` entries are gated via the stable name prefix in `Manifest()` (a future
never the automount's own state. **Matrix-corrected:** a rebooted guest inherits neither an idle pbsdr-* op is gated automatically; nothing else ever is — pinned by test).
trigger NOR an active mount, so the callers re-arm reactively when a running guest is found blind - `Summarize` counts only real degraded; `logCapabilities` logs an inactive count at INFO and
(`RearmNetworkAutomount` after a failed visibility check). now runs AFTER the pbsdr wiring so the startup snapshot equals the first report's.
- **F10 — re-arm for real:** a failed/start-limited unit is `reset-failed` before `enable --now` 2. **`pbsdr.Manager.DRConfigured()`** (`internal/pbsdr/manager.go`): the gate's answer — live
(unprivileged `systemctl is-failed` seam + new sudoers verb). descriptor state (anything but `disabled`), falling back to the persisted converged marker
- **F9 — say what you did:** enumerate by marker-owned unit files; one INFO verdict per share. before the first desired-state fetch (an applied box never flaps inactive across a restart).
- **F2/F1 — zero residue:** `RemoveNetworkMount` (and every verify-fail rollback via it) reset-failed's 3. **F-3** (`internal/provision/backhalf.go`): a root-run provision chowns `guests/` +
the pair and `rmdir`s the mountpoint (rmdir-only fail-safe; never `rm -rf`). `guests/<vmid>/` to the state-dir's owner (`chown --reference`, non-recursive; bootstrap leaf
- **Hook can never take a guest down:** `runHookPhase` — per-phase recover + hard timeout, always stays guest-root). Non-root (daemon) runs skip it via the `geteuid` seam.
returns; the installed wrapper snippet no longer `exec`s (`… || true; exit 0`).
- **Appliance self-heal (`internal/selfheal`):** one heal — host networking recovery, gated on
`deployment_mode:"appliance"`; byo runs the check + WARNs, remedy unreachable. Config
`deployment_mode` (+`FELHOM_AGENT_DEPLOYMENT_MODE`, `IsAppliance()` fail-safe-to-byo).
## Sudoers — three grants added (LOUD, per the no-widening rule) ## Tests + red-proofs (all three mutations proven red, then restored)
Shipped with the binary; capability manifest updated (probed live: 62/62, 0 degraded): | Test | Red-proof mutation | Result |
1. **`systemctl reset-failed -- mnt-felhom*`** (FELHOM_NETMOUNT) — F10: no platform path could re-arm a |---|---|---|
start-limited automount. reset-failed only clears a failed latch; it cannot start/stop anything. | `TestProbe_GateOffHealthyIsInactive` (+ BinaryMissingStaysDegraded, GateOnOrNilIsOK, ExactlyPBSDRGated) | gate branch in `Probe` disabled (`if false`) | inactive case FAILED; degraded case stayed green ✓ |
2. **`rmdir /mnt/felhom-drives/*`** (FELHOM_NETMOUNT) — F1: removed-share mountpoint cleanup. rmdir | `TestDRConfigured_Lifecycle` (unconfigured→applied→restart-marker→disabled) | `DRConfigured` ignores the `disabled` state | disabled case FAILED ✓ |
refuses a non-empty dir (fail-safe; unexpected data is preserved, never `rm -rf`'d). | `TestProvision_RootRunOwnsGuestsParents` (root issues, non-root doesn't, never `-R`) | F-3 chown block removed | root case FAILED ✓ |
3. **`systemctl start networking.service`** (new FELHOM_SELFHEAL alias) — F12-class appliance self-heal.
Fixed unit, no glob; the grant alone cannot harm; the remedy is ALSO code-gated on appliance mode.
## Tests + red-proofs (all green) ## Live state touched
- Render: no network-online in either unit, `_netdev` present (companion: re-adding the line fails). - **felhom-pve latent F-3 state FIXED live** (pre-existing `guests{,/9201}` root:root 0700 →
- Reconcile: drifted (legacy) unit rewritten once + batched daemon-reload; idempotent (0 rewrites 2nd chowned to felhom-agent, bootstrap subtree untouched — the drill's proven fix).
pass); spec round-trip exact; foreign units ignored. - **Peti's host: unreachable** — his F-3 check + agent update stay deferred (publish-train D/E/G).
- Reassert: fstype table with the automount-state-ignored red-proof; reset-failed+rearm verdict;
per-unit verdict count (F9); re-arm-on-guest-blind (F11 matrix-correction); byo-blind non-fatal.
- Hook rc-0 under panic + timeout (Part 4); zero-residue (reset-failed + rmdir, never rm -rf).
- Self-heal state machine; **byo-never-invokes red-proof** (byo + unhealthy → zero privileged
invocations); absent field ⇒ byo.
## Live MATRIX (demo felhom-pve, agent 0.85.0 + deployment_mode=appliance) ## Shipping notes
Deploy verified: startup migrated the installed nas-media units (0 network-online lines); no interim - `configs/felhom-pbs-apply` was already in this repo; host-install **v1.15.0** (felhom.eu,
drop-in existed to remove (the campaign recovery used reset-failed+restart, not a drop-in). same-day) now ships it (drill F-7) + `age` (F-10) + `wg_tunnel.enabled: true` (F-9/decision 5).
- Hub-side rendering of the `inactive` chip + the per-customer DR flag = hub v0.51.0 (felhom.eu).
| # | Row | Result | - Deploy: build+push 0.86.0 on 180, deploy to felhom-pve, publish via `scripts/publish-agent.sh`,
|---|-----|--------| hub Day-0 manifest vouch as operator/browser follow-up.
| 1 | guest reboot × idle share ×2 | **PASS** — zero-touch: reassert re-armed, guest sees NAS, write-through OK, 4 NAS apps auto-started |
| 2 | guest reboot × active share | **F13 (new finding)** — active nfs4 under mp8 can fail PVE's rbind (rc255, idmap); NAS-down-at-boot → apps `Created` (deferred fix-3). Independent of the felhom hook (rc0). |
| 3 | F10 manufacture → agent restart | **PASS** — sweep `reset-failed` both units (loud WARN) + re-armed (verdict `reset-failed+rearmed`); the campaign's silent no-op is gone |
| 4 | F10 state → guest reboot (hook path) | **PASS** — guest STARTS (rc0); F11 matrix-correction healed the blind guest live ("re-arming … visible after re-arm (healed)") |
| 5 | 🛑 host reboot ×2 | **PASS**`grep -c "ordering cycle"` == **0 on BOTH boots**, networking up, automount armed, guest + 8 apps healthy. **F12 coin flip is dead.** |
| 6 | 🛑 stop networking (appliance) | **PASS** — watchdog WARNed `active=false`, fired the privileged start, recovered on attempt 1 (~11 s). BYO live variant NOT run (red-proof only). |
| 7 | refused add + remove cycles ×3 | **PASS** — zero residue (0 units/dirs/failed/creds; campaign left 10 dirs); registry clean |
## NOT live-validated / standing items
- **Row 6 byo variant** — not run live (would leave the box dark); byo is red-proof-tested only.
- **F13 (NEW, HIGH)** — active-nfs-under-mp8 rc255 + NAS-down-at-boot apps-`Created`. Deferred (needs a
pre-start idle-unmount design for multi-guest hosts, or an idmapped nfs mount + fix-3 alerting).
- **Peti** runs agent 0.81 — unaffected by this train until his rollout; if his box has a NAS share he
carries the F12 coin flip until then (interim drop-in recipe below).
- Task B/C/D findings (F7/F6/F5, F8/F4, ring/alerting) remain queued.
- **Publish train:** 0.85 is customer-critical (F12 affects any customer with a NAS share) — prioritize.
## Box state at wrap
agent 0.85.0 / deployment_mode=appliance; nas-media clean (no ordering cycle) + active; 0 failed felhom
units; 0 leftover mountpoint dirs (19 pre-fix campaign leftovers rmdir'd); guest 9201 + all 10
containers healthy with NAS data intact.
### Interim F12 mitigation for a not-yet-upgraded host
A systemd drop-in on each installed `mnt-felhom…-<name>.automount` with empty `After=`/`Wants=`
overrides + `daemon-reload` strips the ordering until the 0.85 agent migrates the unit at startup.
+1 -1
View File
@@ -13,7 +13,7 @@
| `Runner` / `ExecRunner.Run`, `RunStdin` | internal/proxmox/privileged.go | `Run(ctx, name, args...) (stdout, stderr []byte, err)` | ALL host command exec (direct or `sudo -n` prefix) | Arg vectors only, never a shell string; `capBuf` caps output at 1 MiB | | `Runner` / `ExecRunner.Run`, `RunStdin` | internal/proxmox/privileged.go | `Run(ctx, name, args...) (stdout, stderr []byte, err)` | ALL host command exec (direct or `sudo -n` prefix) | Arg vectors only, never a shell string; `capBuf` caps output at 1 MiB |
| `Privileged` (CreateGoldenLXC/MountUSBByUUID/SMART/Sensors) | internal/proxmox/privileged.go | methods on `*Privileged` | the 3 fenced root-CLI exceptions ONLY | Do NOT add methods — fence is structural (`routing_test.go` asserts it) | | `Privileged` (CreateGoldenLXC/MountUSBByUUID/SMART/Sensors) | internal/proxmox/privileged.go | methods on `*Privileged` | the 3 fenced root-CLI exceptions ONLY | Do NOT add methods — fence is structural (`routing_test.go` asserts it) |
| `SudoHostOps.run` | internal/storage/hostops.go | `run(ctx, name, args...) error` | allowlisted exec with stderr-wrapped error | Every arg pre-validated via validate.go before this is called | | `SudoHostOps.run` | internal/storage/hostops.go | `run(ctx, name, args...) error` | allowlisted exec with stderr-wrapped error | Every arg pre-validated via validate.go before this is called |
| `Prober.Probe` | internal/capability/probe.go | `Probe(ctx) []Status` | live sudo-policy capability check (`sudo -n -l --`) | Needs a DIRECT runner (never the sudo-prefixing one — double-sudo); never executes probed cmds | | `Prober.Probe` | internal/capability/probe.go | `Probe(ctx) []Status` | live sudo-policy capability check (`sudo -n -l --`) | Needs a DIRECT runner (never the sudo-prefixing one — double-sudo); never executes probed cmds. v0.86.0: config-gated caps (`Capability.GatedBy` + `Prober.GateActive`) report `inactive`/"disabled by configuration" ONLY when healthy — broken plumbing stays degraded; the pbsdr-* gate answers from `pbsdr.Manager.DRConfigured` (marker-backed across restarts) |
| `stageTemp` | internal/localapi/intermediary.go | `stageTemp(pattern, content) (path, err)` | random-named temp before a root `install` (audit B1) | Fixed /tmp names are a TOCTOU — sudoers globs expect `/tmp/felhom-*-*.ext` | | `stageTemp` | internal/localapi/intermediary.go | `stageTemp(pattern, content) (path, err)` | random-named temp before a root `install` (audit B1) | Fixed /tmp names are a TOCTOU — sudoers globs expect `/tmp/felhom-*-*.ext` |
| `guesthook.InstallSnippet` / `Register` | internal/guesthook/install.go | `InstallSnippet(ctx, runner) error` | pre-start self-heal hook install (C1 net) | Same random-temp+install pattern; snippet delegates to the agent binary (no shell logic). Issues `mkdir -p /var/lib/vz/snippets` FIRST (v0.63.0, B2 — fresh boxes lack the dir; sudoers grants exactly that argv) | | `guesthook.InstallSnippet` / `Register` | internal/guesthook/install.go | `InstallSnippet(ctx, runner) error` | pre-start self-heal hook install (C1 net) | Same random-temp+install pattern; snippet delegates to the agent binary (no shell logic). Issues `mkdir -p /var/lib/vz/snippets` FIRST (v0.63.0, B2 — fresh boxes lack the dir; sudoers grants exactly that argv) |
+26 -4
View File
@@ -53,7 +53,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.63.0" var version = "0.86.0"
// runGuestHook is the PVE hook body (`felhom-agent guest-hook <vmid> <phase>`). On pre-start it // runGuestHook is the PVE hook body (`felhom-agent guest-hook <vmid> <phase>`). On pre-start it
// creates placeholder dirs for any absent bind-mount source so the guest always boots (the C1 net); // creates placeholder dirs for any absent bind-mount source so the guest always boots (the C1 net);
@@ -399,10 +399,12 @@ func poolReadStatus(ctx context.Context, px *proxmox.Client) capability.Status {
// logCapabilities logs the privileged-capability self-check at startup: one INFO summary, plus an // logCapabilities logs the privileged-capability self-check at startup: one INFO summary, plus an
// ERROR per degraded capability naming the gated feature (so a missing grant is loud at cutover, // ERROR per degraded capability naming the gated feature (so a missing grant is loud at cutover,
// not days later). It never exits — serve-degraded. // not days later). Inactive (config-gated off, plumbing healthy — v0.86.0) is counted in the
// summary but never error-logged: disabled ≠ broken. It never exits — serve-degraded.
func logCapabilities(statuses []capability.Status, logger *slog.Logger) { func logCapabilities(statuses []capability.Status, logger *slog.Logger) {
ok, total, degraded := capability.Summarize(statuses) ok, total, degraded := capability.Summarize(statuses)
logger.Info("capabilities self-check", "ok", ok, "total", total, "degraded", len(degraded)) logger.Info("capabilities self-check",
"ok", ok, "total", total, "degraded", len(degraded), "inactive", total-ok-len(degraded))
for _, d := range degraded { for _, d := range degraded {
logger.Error("capability DEGRADED — privileged grant missing (feature impaired until fixed)", logger.Error("capability DEGRADED — privileged grant missing (feature impaired until fixed)",
"capability", d.Name, "feature", d.Feature, "reason", d.Reason, "critical", d.Critical) "capability", d.Name, "feature", d.Feature, "reason", d.Reason, "critical", d.Critical)
@@ -459,13 +461,27 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// startup (loud on any denial) and attach the snapshot to every hub report; the hub owns the // startup (loud on any denial) and attach the snapshot to every hub report; the hub owns the
// ok→degraded alert. Serve-degraded — a missing grant never blocks startup. // ok→degraded alert. Serve-degraded — a missing grant never blocks startup.
capProber := capability.Prober{Runner: &proxmox.ExecRunner{Mode: proxmox.RunnerDirect}} capProber := capability.Prober{Runner: &proxmox.ExecRunner{Mode: proxmox.RunnerDirect}}
// DR-tier gate (v0.86.0, DR-tier-by-default): the pbsdr-* capabilities are config-gated — on a
// box whose DR tier is not configured (no descriptor ever / descriptor disabled) a HEALTHY
// probe reports "inactive (disabled by configuration)" instead of ok; broken plumbing (binary
// missing / grant denied) stays DEGRADED regardless (an un-migrated box must never look
// deliberately off). Late-bound: the pbsdr manager is constructed further down; probes run at
// report time. nil (pre-assignment) fails ACTIVE — the historical behavior.
var drConfigured func() bool
capProber.GateActive = func(gate string) bool {
if gate == capability.GatePBSDR && drConfigured != nil {
return drConfigured()
}
return true
}
// A1 (v0.62.0): compose the PVE pool-read check AROUND the sudo prober (an API read does not // A1 (v0.62.0): compose the PVE pool-read check AROUND the sudo prober (an API read does not
// belong inside the sudo-policy probe). Non-critical: a degraded pool read means the stale-lock // belong inside the sudo-policy probe). Non-critical: a degraded pool read means the stale-lock
// reaper fail-safes (locks stay uncleared) — visible on the hub report, no operator page. // reaper fail-safes (locks stay uncleared) — visible on the hub report, no operator page.
probeAll := func(ctx context.Context) []capability.Status { probeAll := func(ctx context.Context) []capability.Status {
return append(capProber.Probe(ctx), poolReadStatus(ctx, px)) return append(capProber.Probe(ctx), poolReadStatus(ctx, px))
} }
logCapabilities(probeAll(context.Background()), logger) // (The startup self-check log runs AFTER the pbsdr manager is wired below, so its snapshot
// already carries the gated view — v0.86.0.)
collector.SetCapabilityProber(probeAll) collector.SetCapabilityProber(probeAll)
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger) loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
interval := time.Duration(hcfg.PollSeconds) * time.Second interval := time.Duration(hcfg.PollSeconds) * time.Second
@@ -796,8 +812,14 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
pbsdrLoop = pbsdr.NewLoop(pdMgr, 60*time.Second, logger) pbsdrLoop = pbsdr.NewLoop(pdMgr, 60*time.Second, logger)
desiredSyncer.AddConsumer(pbsdrLoop) // raw desired-state → the pbs_dr block desiredSyncer.AddConsumer(pbsdrLoop) // raw desired-state → the pbs_dr block
collector.SetPBSDRReporter(pbsdrLoop) collector.SetPBSDRReporter(pbsdrLoop)
// Capability gate wiring (v0.86.0): the prober's GatePBSDR now answers from the bridge
// (descriptor state, marker-backed across restarts) — see the capProber block above.
drConfigured = pdMgr.DRConfigured
logger.Info("pbsdr: bridge enabled (hub-driven; no-op until a pbs_dr descriptor arrives)") logger.Info("pbsdr: bridge enabled (hub-driven; no-op until a pbs_dr descriptor arrives)")
} }
// Startup capability self-check — after the pbsdr gate wiring so the logged snapshot matches
// what the first report will carry (inactive vs degraded is already resolved here).
logCapabilities(probeAll(context.Background()), logger)
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS // Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop // verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
+86 -62
View File
@@ -12,6 +12,8 @@
// by a sudoers pattern, catching authoring gaps in CI before they ship. // by a sudoers pattern, catching authoring gaps in CI before they ship.
package capability package capability
import "strings"
// Capability is one privileged command the agent depends on. Name is a stable id; Feature is the // Capability is one privileged command the agent depends on. Name is a stable id; Feature is the
// human-readable thing that breaks if the grant is missing (used in logs + the operator alert). // human-readable thing that breaks if the grant is missing (used in logs + the operator alert).
// Binary is the absolute path the runner invokes; ReprArgs is a CONCRETE argument vector that // Binary is the absolute path the runner invokes; ReprArgs is a CONCRETE argument vector that
@@ -24,8 +26,16 @@ type Capability struct {
Binary string Binary string
ReprArgs []string ReprArgs []string
Critical bool Critical bool
// GatedBy names the config gate this capability sits behind ("" = always active). A healthy
// probe of a gated capability whose gate is OFF reports StatusInactive instead of ok — see
// Prober.GateActive. Broken plumbing stays degraded regardless of the gate.
GatedBy string
} }
// GatePBSDR is the gate id for the PBS-DR tier: active when this box carries a live (enabled)
// pbs_dr descriptor or a previously-converged marker (pbsdr.Manager.DRConfigured).
const GatePBSDR = "pbs_dr"
// Manifest is the required set, seeded from the 2026-06-29 sudoers audit (felhom-agent/REPORT.md): // Manifest is the required set, seeded from the 2026-06-29 sudoers audit (felhom-agent/REPORT.md):
// the OK + newly-CLOSED rows. The SURFACED/DEFERRED rows are deliberately EXCLUDED — they are not // the OK + newly-CLOSED rows. The SURFACED/DEFERRED rows are deliberately EXCLUDED — they are not
// required capabilities: the general `pct exec <vmid> -- *` (controller-swap; arbitrary exec, an // required capabilities: the general `pct exec <vmid> -- *` (controller-swap; arbitrary exec, an
@@ -35,7 +45,21 @@ type Capability struct {
// //
// Each ReprArgs is a representative instance; the probe LISTS it (`sudo -n -l`) and never runs it, // Each ReprArgs is a representative instance; the probe LISTS it (`sudo -n -l`) and never runs it,
// so even mkfs/pct-set entries are side-effect-free to probe. // so even mkfs/pct-set entries are side-effect-free to probe.
func Manifest() []Capability { return manifest } //
// Gating (v0.86.0): every `pbsdr-*` capability is behind GatePBSDR — the whole pbsdr vector IS
// the DR-tier apply path, so the gate rides the stable name prefix (a new pbsdr-* op is gated
// automatically; nothing else ever is). Applied here, not in the literals, to keep the manifest
// entries positional.
func Manifest() []Capability {
out := make([]Capability, len(manifest))
copy(out, manifest)
for i := range out {
if strings.HasPrefix(out[i].Name, "pbsdr-") {
out[i].GatedBy = GatePBSDR
}
}
return out
}
// reprFingerprint is a shape-valid all-zero SHA-256 colon fingerprint for list-mode repr vectors // reprFingerprint is a shape-valid all-zero SHA-256 colon fingerprint for list-mode repr vectors
// (matches the wrapper's fingerprint validation; never executed). // (matches the wrapper's fingerprint validation; never executed).
@@ -43,102 +67,102 @@ const reprFingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:0
var manifest = []Capability{ var manifest = []Capability{
// ---- Intermediary drive model (the multi-drive path — mostly Critical) ---- // ---- Intermediary drive model (the multi-drive path — mostly Critical) ----
{"guest-init-pid", "drive-gate guest-sees check (multi-drive concurrency)", "/usr/bin/lxc-info", []string{"-n", "9201", "-p", "-H"}, true}, {"guest-init-pid", "drive-gate guest-sees check (multi-drive concurrency)", "/usr/bin/lxc-info", []string{"-n", "9201", "-p", "-H"}, true, ""},
{"parent-self-bind", "intermediary shared-parent self-bind", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-drives", "/mnt/felhom-drives"}, true}, {"parent-self-bind", "intermediary shared-parent self-bind", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-drives", "/mnt/felhom-drives"}, true, ""},
{"parent-make-shared", "intermediary shared-parent propagation", "/usr/bin/mount", []string{"--make-shared", "/mnt/felhom-drives"}, true}, {"parent-make-shared", "intermediary shared-parent propagation", "/usr/bin/mount", []string{"--make-shared", "/mnt/felhom-drives"}, true, ""},
{"parent-make-private", "intermediary shared-parent peer-group isolation", "/usr/bin/mount", []string{"--make-private", "/mnt/felhom-drives"}, true}, {"parent-make-private", "intermediary shared-parent peer-group isolation", "/usr/bin/mount", []string{"--make-private", "/mnt/felhom-drives"}, true, ""},
{"drive-bind", "drive attach (felhom-data bind under parent)", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-usb/felhom-data", "/mnt/felhom-drives/felhom-usb"}, true}, {"drive-bind", "drive attach (felhom-data bind under parent)", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-usb/felhom-data", "/mnt/felhom-drives/felhom-usb"}, true, ""},
{"drive-umount", "drive detach (fail-closed unmount)", "/usr/bin/umount", []string{"/mnt/felhom-drives/felhom-usb"}, true}, {"drive-umount", "drive detach (fail-closed unmount)", "/usr/bin/umount", []string{"/mnt/felhom-drives/felhom-usb"}, true, ""},
{"drives-mkdir-parent", "stable parent dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives"}, false}, {"drives-mkdir-parent", "stable parent dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives"}, false, ""},
{"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false}, {"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false, ""},
{"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false}, {"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false, ""},
{"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false}, {"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false, ""},
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false}, {"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false, ""},
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false}, {"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false, ""},
{"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false}, {"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false, ""},
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false}, {"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false, ""},
// ---- Disk inspect / format gate (Critical: the data-bearing classifier + format) ---- // ---- Disk inspect / format gate (Critical: the data-bearing classifier + format) ----
{"disk-blkid", "disk data-bearing classify (format gate)", "/usr/sbin/blkid", []string{"-p", "-o", "export", "/dev/sda"}, true}, {"disk-blkid", "disk data-bearing classify (format gate)", "/usr/sbin/blkid", []string{"-p", "-o", "export", "/dev/sda"}, true, ""},
{"disk-lsblk", "disk topology read (format gate)", "/usr/bin/lsblk", []string{"-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", "/dev/sda"}, true}, {"disk-lsblk", "disk topology read (format gate)", "/usr/bin/lsblk", []string{"-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", "/dev/sda"}, true, ""},
{"disk-mkfs-ext4", "guarded format (ext4)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "ext4"}, true}, {"disk-mkfs-ext4", "guarded format (ext4)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "ext4"}, true, ""},
{"disk-mkfs-xfs", "guarded format (xfs)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "xfs"}, false}, {"disk-mkfs-xfs", "guarded format (xfs)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "xfs"}, false, ""},
{"disk-smart", "disk SMART health read", "/usr/sbin/smartctl", []string{"-a", "-j", "/dev/sda"}, false}, {"disk-smart", "disk SMART health read", "/usr/sbin/smartctl", []string{"-a", "-j", "/dev/sda"}, false, ""},
{"disk-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false}, {"disk-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false, ""},
// ---- Storage mount units (watchdog re-mount) ---- // ---- Storage mount units (watchdog re-mount) ----
{"mount-unit-install", "fs-UUID mount unit install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0644", "--", "/var/lib/felhom-agent/units/felhom-x.mount", "/etc/systemd/system/felhom-x.mount"}, false}, {"mount-unit-install", "fs-UUID mount unit install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0644", "--", "/var/lib/felhom-agent/units/felhom-x.mount", "/etc/systemd/system/felhom-x.mount"}, false, ""},
{"mount-daemon-reload", "systemd reload after unit write", "/usr/bin/systemctl", []string{"daemon-reload"}, false}, {"mount-daemon-reload", "systemd reload after unit write", "/usr/bin/systemctl", []string{"daemon-reload"}, false, ""},
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "felhom-x.mount"}, false}, {"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "felhom-x.mount"}, false, ""},
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "felhom-x.mount"}, false}, {"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "felhom-x.mount"}, false, ""},
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false}, {"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false, ""},
// ---- Network storage re-arm + cleanup (CAMPAIGN-3 F10/F1) ---- // ---- Network storage re-arm + cleanup (CAMPAIGN-3 F10/F1) ----
{"netmount-reset-failed", "NAS automount re-arm after start-limit (F10)", "/usr/bin/systemctl", []string{"reset-failed", "--", "mnt-felhom\\x2ddrives-media.automount"}, false}, {"netmount-reset-failed", "NAS automount re-arm after start-limit (F10)", "/usr/bin/systemctl", []string{"reset-failed", "--", "mnt-felhom\\x2ddrives-media.automount"}, false, ""},
{"netmount-rmdir", "removed-share mountpoint cleanup (F1)", "/usr/bin/rmdir", []string{"/mnt/felhom-drives/media"}, false}, {"netmount-rmdir", "removed-share mountpoint cleanup (F1)", "/usr/bin/rmdir", []string{"/mnt/felhom-drives/media"}, false, ""},
// ---- Node self-heal (CAMPAIGN-3 F12-class, appliance-gated in code) ---- // ---- Node self-heal (CAMPAIGN-3 F12-class, appliance-gated in code) ----
{"selfheal-networking-start", "appliance networking recovery at boot (F12 defense in depth)", "/usr/bin/systemctl", []string{"start", "networking.service"}, false}, {"selfheal-networking-start", "appliance networking recovery at boot (F12 defense in depth)", "/usr/bin/systemctl", []string{"start", "networking.service"}, false, ""},
// ---- Provisioning back-half ---- // ---- Provisioning back-half ----
{"provision-chown", "bootstrap mount guest-root chown", "/usr/bin/chown", []string{"-R", "100000:100000", "/var/lib/felhom-agent/guests/9201"}, false}, {"provision-chown", "bootstrap mount guest-root chown", "/usr/bin/chown", []string{"-R", "100000:100000", "/var/lib/felhom-agent/guests/9201"}, false, ""},
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201"}, false}, {"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201"}, false, ""},
{"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false}, {"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false, ""},
// ---- Pre-start self-heal hook + guest lifecycle ---- // ---- Pre-start self-heal hook + guest lifecycle ----
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false}, {"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false, ""},
{"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false}, {"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false, ""},
{"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false}, {"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false, ""},
{"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false}, {"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false, ""},
// ---- LAN split-horizon resolver (dnsmasq) ---- // ---- LAN split-horizon resolver (dnsmasq) ----
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false}, {"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
{"dnsmasq-write", "dnsmasq drop-in write", "/usr/bin/install", []string{"-m", "0644", "/tmp/felhom-resolver-x.conf", "/etc/dnsmasq.d/felhom-x.conf"}, false}, {"dnsmasq-write", "dnsmasq drop-in write", "/usr/bin/install", []string{"-m", "0644", "/tmp/felhom-resolver-x.conf", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
{"dnsmasq-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false}, {"dnsmasq-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false, ""},
{"dnsmasq-reload", "dnsmasq reload", "/usr/bin/systemctl", []string{"reload", "dnsmasq"}, false}, {"dnsmasq-reload", "dnsmasq reload", "/usr/bin/systemctl", []string{"reload", "dnsmasq"}, false, ""},
{"dnsmasq-restart", "dnsmasq restart (LAN-DNS self-heal)", "/usr/bin/systemctl", []string{"restart", "dnsmasq"}, false}, {"dnsmasq-restart", "dnsmasq restart (LAN-DNS self-heal)", "/usr/bin/systemctl", []string{"restart", "dnsmasq"}, false, ""},
{"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false}, {"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
{"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false}, {"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false, ""},
{"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false}, {"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false, ""},
// ---- Controller-swap / managed auto-update (FELHOM_CONTROLLERSWAP, v0.45.0; Critical: a // ---- Controller-swap / managed auto-update (FELHOM_CONTROLLERSWAP, v0.45.0; Critical: a
// silently-broken fleet auto-update is operator-alert-worthy) ---- // silently-broken fleet auto-update is operator-alert-worthy) ----
{"controllerswap-read", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "cat", "/etc/felhom-controller-image"}, true}, {"controllerswap-read", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "cat", "/etc/felhom-controller-image"}, true, ""},
{"controllerswap-image-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "image", "inspect", "gitea.dooplex.hu/admin/felhom-controller:0.0.0"}, true}, {"controllerswap-image-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "image", "inspect", "gitea.dooplex.hu/admin/felhom-controller:0.0.0"}, true, ""},
{"controllerswap-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "inspect", "-f", "{{.State.Running}}", "felhom-controller"}, true}, {"controllerswap-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "inspect", "-f", "{{.State.Running}}", "felhom-controller"}, true, ""},
{"controllerswap-restart", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "systemctl", "restart", "felhom-controller-bootstrap.service"}, true}, {"controllerswap-restart", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "systemctl", "restart", "felhom-controller-bootstrap.service"}, true, ""},
{"controllerswap-write", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "tee", "/etc/felhom-controller-image"}, true}, {"controllerswap-write", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "tee", "/etc/felhom-controller-image"}, true, ""},
// ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale // ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ---- // reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true}, {"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true, ""},
// ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical FLIPPED in S4/v0.66.0 — offsite // ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical FLIPPED in S4/v0.66.0 — offsite
// backups now RIDE the tunnel, so a degraded tunnel capability is operator-alert-worthy: the // backups now RIDE the tunnel, so a degraded tunnel capability is operator-alert-worthy: the
// conf install, unit enable/restart and the handshake read gate the backup path. apt-install // conf install, unit enable/restart and the handshake read gate the backup path. apt-install
// (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The // (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The
// handshake read is the ONLY wg invocation (never `dump`). ---- // handshake read is the ONLY wg invocation (never `dump`). ----
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false}, {"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false, ""},
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, true}, {"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, true, ""},
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true}, {"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true, ""},
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true}, {"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true, ""},
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false}, {"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false, ""},
{"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, true}, {"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, true, ""},
// ---- PBS DR tier apply (FELHOM_PBSDR, slice 2). NON-critical (the selfupdate rationale): // ---- PBS DR tier apply (FELHOM_PBSDR, slice 2). NON-critical (the selfupdate rationale):
// applying the tier is an occasional hub-driven provisioning op, not a steady-state serving // applying the tier is an occasional hub-driven provisioning op, not a steady-state serving
// path — a degraded grant means "can't provision/reconcile the PBS entry" (the bridge reports // path — a degraded grant means "can't provision/reconcile the PBS entry" (the bridge reports
// loudly anyway), not a serving outage. The steady-state backup path is covered by the wg + // loudly anyway), not a serving outage. The steady-state backup path is covered by the wg +
// storage capabilities. List-mode representations only; never executed. ---- // storage capabilities. List-mode representations only; never executed. ----
{"pbsdr-create", "PBS DR storage-entry create (K autogen)", "/usr/local/sbin/felhom-pbs-apply", []string{"create", "felhom-pbs", "10.77.0.1", "felhom-offsite", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false}, {"pbsdr-create", "PBS DR storage-entry create (K autogen)", "/usr/local/sbin/felhom-pbs-apply", []string{"create", "felhom-pbs", "10.77.0.1", "felhom-offsite", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false, ""},
{"pbsdr-reconcile", "PBS DR storage-entry reconcile (set-only)", "/usr/local/sbin/felhom-pbs-apply", []string{"reconcile", "felhom-pbs", "10.77.0.1", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false}, {"pbsdr-reconcile", "PBS DR storage-entry reconcile (set-only)", "/usr/local/sbin/felhom-pbs-apply", []string{"reconcile", "felhom-pbs", "10.77.0.1", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false, ""},
{"pbsdr-grant", "PBS DR storage ACL self-grant", "/usr/local/sbin/felhom-pbs-apply", []string{"grant", "felhom-pbs"}, false}, {"pbsdr-grant", "PBS DR storage ACL self-grant", "/usr/local/sbin/felhom-pbs-apply", []string{"grant", "felhom-pbs"}, false, ""},
// ---- Agent self-update (FELHOM_SELFUPDATE, D1). NON-critical: self-update is an occasional // ---- Agent self-update (FELHOM_SELFUPDATE, D1). NON-critical: self-update is an occasional
// operator-driven op, not a steady-state serving path — a degraded grant means "can't // operator-driven op, not a steady-state serving path — a degraded grant means "can't
// self-update" (fall back to a manual SSH deploy), not a serving outage. The apply repr uses a // self-update" (fall back to a manual SSH deploy), not a serving outage. The apply repr uses a
// staging-dir path + a placeholder sha (list-mode never runs it). ---- // staging-dir path + a placeholder sha (list-mode never runs it). ----
{"selfupdate-apply", "agent self-update apply (A/B flip)", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"apply", "/var/lib/felhom-agent/selfupdate/felhom-agent-0.0.0", "0000000000000000000000000000000000000000000000000000000000000000"}, false}, {"selfupdate-apply", "agent self-update apply (A/B flip)", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"apply", "/var/lib/felhom-agent/selfupdate/felhom-agent-0.0.0", "0000000000000000000000000000000000000000000000000000000000000000"}, false, ""},
{"selfupdate-commit", "agent self-update commit", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"commit"}, false}, {"selfupdate-commit", "agent self-update commit", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"commit"}, false, ""},
{"selfupdate-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false}, {"selfupdate-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false, ""},
} }
+29 -6
View File
@@ -8,19 +8,30 @@ import (
// Status is one capability's live result — the wire shape the agent attaches to its hub report // Status is one capability's live result — the wire shape the agent attaches to its hub report
// (HostReport.Capabilities). The hub mirrors this struct field-for-field and keys its alert on // (HostReport.Capabilities). The hub mirrors this struct field-for-field and keys its alert on
// Critical+degraded. Reason is empty when ok. // Critical+degraded. Reason is empty when ok.
//
// "inactive" (v0.86.0, DR-tier-by-default): a config-GATED capability whose plumbing is HEALTHY
// (binary present, sudo granted) but whose gating feature is disabled by configuration. Distinct
// from degraded on purpose — disabled ≠ broken; the hub renders it as a neutral chip, never red.
// Broken plumbing (binary missing / grant denied) stays DEGRADED even when the gate is off: an
// un-migrated box must never look deliberately disabled.
type Status struct { type Status struct {
Name string `json:"name"` Name string `json:"name"`
Feature string `json:"feature"` Feature string `json:"feature"`
Critical bool `json:"critical"` Critical bool `json:"critical"`
Status string `json:"status"` // "ok" | "degraded" Status string `json:"status"` // "ok" | "degraded" | "inactive"
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
const ( const (
StatusOK = "ok" StatusOK = "ok"
StatusDegraded = "degraded" StatusDegraded = "degraded"
StatusInactive = "inactive"
) )
// ReasonInactive is the fixed reason string for the inactive state (the hub + operator docs
// reference it verbatim).
const ReasonInactive = "disabled by configuration"
// Runner is the minimal exec seam the probe needs (satisfied by proxmox.ExecRunner). The probe // Runner is the minimal exec seam the probe needs (satisfied by proxmox.ExecRunner). The probe
// runs `sudo -n -l -- <binary> <args…>` LITERALLY — a sudo POLICY LIST that never executes the // runs `sudo -n -l -- <binary> <args…>` LITERALLY — a sudo POLICY LIST that never executes the
// command — so the Runner MUST be a DIRECT runner (RunnerDirect), not the sudo-prepending one // command — so the Runner MUST be a DIRECT runner (RunnerDirect), not the sudo-prepending one
@@ -31,9 +42,13 @@ type Runner interface {
// Prober checks the manifest against the live host. Exists defaults to an os.Stat check on the // Prober checks the manifest against the live host. Exists defaults to an os.Stat check on the
// absolute binary path (what `command -v` would resolve for an absolute path) when nil. // absolute binary path (what `command -v` would resolve for an absolute path) when nil.
// GateActive answers "is the feature behind this gate id configured on?" for GATED capabilities
// (Capability.GatedBy). nil, or a gate it answers true for, keeps the historical behavior; false
// downgrades a HEALTHY probe to StatusInactive (broken plumbing stays degraded regardless).
type Prober struct { type Prober struct {
Runner Runner Runner Runner
Exists func(path string) bool // nil → os.Stat Exists func(path string) bool // nil → os.Stat
GateActive func(gate string) bool // nil → every gate treated active
} }
// Probe lists every manifest capability against the sudo policy and checks its binary exists, // Probe lists every manifest capability against the sudo policy and checks its binary exists,
@@ -72,6 +87,11 @@ func (p Prober) Probe(ctx context.Context) []Status {
case p.Runner != nil && !p.granted(ctx, c): case p.Runner != nil && !p.granted(ctx, c):
s.Status, s.Reason = StatusDegraded, "sudo policy denied" s.Status, s.Reason = StatusDegraded, "sudo policy denied"
} }
// Config gate (v0.86.0): only a HEALTHY probe is downgraded to inactive — a degraded one
// stays degraded (missing binary/grant = un-migrated or mis-installed box, never "off").
if s.Status == StatusOK && c.GatedBy != "" && p.GateActive != nil && !p.GateActive(c.GatedBy) {
s.Status, s.Reason = StatusInactive, ReasonInactive
}
out = append(out, s) out = append(out, s)
} }
return out return out
@@ -85,13 +105,16 @@ func (p Prober) granted(ctx context.Context, c Capability) bool {
return err == nil return err == nil
} }
// Summarize returns (okCount, total, degraded) for logging. degraded lists every non-ok status. // Summarize returns (okCount, total, degraded) for logging. degraded lists DEGRADED statuses
// only — inactive is a deliberate, healthy state and must not land in the error log (it is
// counted via len(statuses)-ok-len(degraded) by callers that want it).
func Summarize(statuses []Status) (ok, total int, degraded []Status) { func Summarize(statuses []Status) (ok, total int, degraded []Status) {
total = len(statuses) total = len(statuses)
for _, s := range statuses { for _, s := range statuses {
if s.Status == StatusOK { switch s.Status {
case StatusOK:
ok++ ok++
} else { case StatusDegraded:
degraded = append(degraded, s) degraded = append(degraded, s)
} }
} }
+79
View File
@@ -115,3 +115,82 @@ func TestProbe_NilRunnerNoPanic(t *testing.T) {
t.Fatalf("nil-runner probe returned %d statuses, want %d", got, len(Manifest())) t.Fatalf("nil-runner probe returned %d statuses, want %d", got, len(Manifest()))
} }
} }
// ── DR-tier gate (v0.86.0) ─────────────────────────────────────────────────────────────────────
// Gate OFF + healthy plumbing → the gated pbsdr-* capabilities report INACTIVE (the neutral
// "disabled by configuration" state), NOT ok and NOT degraded — Scenario B of the DR-by-default
// spec. Ungated siblings are untouched. Red-proof partner: collapse inactive into ok (drop the
// gate branch in Probe) → this fails while TestProbe_GateOffBinaryMissingStaysDegraded passes.
func TestProbe_GateOffHealthyIsInactive(t *testing.T) {
r := &fakeRunner{denyBinary: map[string]bool{}}
p := Prober{
Runner: r,
Exists: func(string) bool { return true },
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
}
statuses := p.Probe(context.Background())
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant"} {
s := find(statuses, name)
if s.Status != StatusInactive || s.Reason != ReasonInactive {
t.Fatalf("%s = %+v, want inactive/%q", name, s, ReasonInactive)
}
}
// An ungated sibling stays plain ok.
if s := find(statuses, "drive-bind"); s.Status != StatusOK {
t.Fatalf("drive-bind = %+v, want ok (ungated)", s)
}
// Summarize must NOT count inactive as degraded (it is not error-log-worthy).
ok, total, degraded := Summarize(statuses)
if len(degraded) != 0 {
t.Fatalf("inactive leaked into degraded: %+v", degraded)
}
if ok != total-3 {
t.Fatalf("ok=%d total=%d, want exactly the 3 gated ones non-ok", ok, total)
}
}
// Gate OFF + BROKEN plumbing (binary missing) → DEGRADED stays degraded. An un-migrated
// pre-v1.15.0 box must never masquerade as deliberately disabled ("never silently pretend").
func TestProbe_GateOffBinaryMissingStaysDegraded(t *testing.T) {
r := &fakeRunner{denyBinary: map[string]bool{}}
p := Prober{
Runner: r,
Exists: func(path string) bool { return path != "/usr/local/sbin/felhom-pbs-apply" },
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
}
statuses := p.Probe(context.Background())
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant"} {
s := find(statuses, name)
if s.Status != StatusDegraded || s.Reason != "binary not found" {
t.Fatalf("%s = %+v, want degraded/binary not found even with the gate off", name, s)
}
}
}
// Gate ON (DR configured) + healthy plumbing → plain ok, exactly the pre-v0.86.0 behavior.
// A nil GateActive must behave the same (fails ACTIVE).
func TestProbe_GateOnOrNilIsOK(t *testing.T) {
for _, gate := range []func(string) bool{nil, func(string) bool { return true }} {
r := &fakeRunner{denyBinary: map[string]bool{}}
p := Prober{Runner: r, Exists: func(string) bool { return true }, GateActive: gate}
statuses := p.Probe(context.Background())
if s := find(statuses, "pbsdr-create"); s.Status != StatusOK {
t.Fatalf("pbsdr-create = %+v, want ok (gate active/nil)", s)
}
}
}
// The gate rides the pbsdr- name prefix: exactly the pbsdr-* manifest entries are gated, nothing
// else (a regression here would silently un-gate the tier or gate an unrelated capability).
func TestManifest_ExactlyPBSDRGated(t *testing.T) {
for _, c := range Manifest() {
wantGated := strings.HasPrefix(c.Name, "pbsdr-")
if gated := c.GatedBy == GatePBSDR; gated != wantGated {
t.Fatalf("%s: GatedBy=%q, want gated=%v", c.Name, c.GatedBy, wantGated)
}
if c.GatedBy != "" && c.GatedBy != GatePBSDR {
t.Fatalf("%s: unknown gate id %q", c.Name, c.GatedBy)
}
}
}
+16
View File
@@ -125,6 +125,22 @@ func (m *Manager) Status() *hub.PBSDRStatus {
return m.status return m.status
} }
// DRConfigured reports whether the DR tier is configured ON for this box — the capability
// prober's GatePBSDR answer (v0.86.0). True when the last-seen descriptor was enabled (any live
// status except "disabled"), or, before the first desired-state fetch of this process, when a
// previously-converged marker exists (so an applied box never flaps to inactive across an agent
// restart). False = no descriptor ever / descriptor disabled → healthy pbsdr capabilities report
// "inactive (disabled by configuration)" instead of ok.
func (m *Manager) DRConfigured() bool {
m.mu.Lock()
st := m.status
m.mu.Unlock()
if st != nil {
return st.State != "disabled"
}
return m.loadMarker() != nil
}
// descriptorHash is the idempotency key: sha256 of the canonical (struct-ordered) JSON. // descriptorHash is the idempotency key: sha256 of the canonical (struct-ordered) JSON.
func descriptorHash(b *hub.WirePBSDR) string { func descriptorHash(b *hub.WirePBSDR) string {
j, _ := json.Marshal(b) j, _ := json.Marshal(b)
+40
View File
@@ -362,6 +362,46 @@ func TestOldHubAndDisabledCompat(t *testing.T) {
} }
} }
// DRConfigured (v0.86.0) — the capability prober's GatePBSDR answer. Walks the full lifecycle:
// unconfigured → enabled(applied) → restart(marker only) → disabled. Red-proof partner: make
// DRConfigured return status!=nil (ignore the "disabled" state) → the disabled case fails.
func TestDRConfigured_Lifecycle(t *testing.T) {
r := &fakeRunner{}
st := &fakeStorage{found: false, active: []bool{true}}
c := &fakeConsumer{secret: "S"}
m, _ := newTestManager(t, r, st, c)
// Fresh box, nothing fetched: not configured.
if m.DRConfigured() {
t.Fatal("fresh manager reports DR configured")
}
// Old hub / no descriptor: still not configured.
m.Apply(context.Background(), true, nil)
if m.DRConfigured() {
t.Fatal("nil-block reports DR configured")
}
// Enabled descriptor applied: configured.
m.Apply(context.Background(), true, testBlock())
if s := m.Status(); s == nil || s.State != "applied" {
t.Fatalf("precondition: status = %+v, want applied", s)
}
if !m.DRConfigured() {
t.Fatal("applied box reports DR NOT configured")
}
// Agent restart (fresh manager over the same state dir): the persisted marker must answer
// BEFORE the first desired-state fetch — an applied box never flaps to inactive at boot.
m2 := NewManager(r, st, c, filepath.Dir(m.stateDir), "/etc/pve/priv/storage", "",
slog.New(slog.NewTextHandler(io.Discard, nil)))
if !m2.DRConfigured() {
t.Fatal("restarted manager (marker on disk) reports DR NOT configured")
}
// Operator turns the tier OFF: descriptor disabled wins over the stale marker.
m2.Apply(context.Background(), true, &hub.WirePBSDR{Enabled: false, StorageID: "felhom-pbs"})
if m2.DRConfigured() {
t.Fatal("disabled descriptor still reports DR configured")
}
}
// TestWireFieldNames pins the cross-repo descriptor contract (hub/internal/web/pbsdr.go // TestWireFieldNames pins the cross-repo descriptor contract (hub/internal/web/pbsdr.go
// pbsDRDescriptor): the exact JSON the hub writes must land in WirePBSDR field-for-field. // pbsDRDescriptor): the exact JSON the hub writes must land in WirePBSDR field-for-field.
func TestWireFieldNames(t *testing.T) { func TestWireFieldNames(t *testing.T) {
+20
View File
@@ -52,6 +52,9 @@ type BackHalf struct {
logger *slog.Logger logger *slog.Logger
} }
// geteuid is the effective-uid seam (F-3): tests fake a root run without being root.
var geteuid = os.Geteuid
// NewBackHalf builds the back-half. stateDir defaults to /var/lib/felhom-agent when empty. // NewBackHalf builds the back-half. stateDir defaults to /var/lib/felhom-agent when empty.
func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf { func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf {
if stateDir == "" { if stateDir == "" {
@@ -135,6 +138,23 @@ func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
if err := os.MkdirAll(hostDir, 0o700); err != nil { if err := os.MkdirAll(hostDir, 0o700); err != nil {
return Result{}, fmt.Errorf("provision: config dir: %w", err) return Result{}, fmt.Errorf("provision: config dir: %w", err)
} }
// 3b. F-3 (DRILL-day0-vm-2026-07-12): a ROOT-run provision (the Day-0 one-shot) leaves the
// just-created guests/ + guests/<vmid>/ parents root:root 0700 inside the agent-owned
// state dir — the non-root daemon's lanresolver then can't traverse them ("permission
// denied"). Own the two PARENTS to whatever owns the state dir (chown --reference; the
// installer made that the agent user). NON-recursive on purpose: only the bootstrap leaf
// below belongs to the mapped guest-root. A daemon-run provision creates them as the
// agent user already — euid≠0 skips (and needs no sudoers vector for this shape).
if geteuid() == 0 {
guestsDir := filepath.Join(b.stateDir, "guests")
vmidDir := filepath.Join(guestsDir, strconv.Itoa(in.VMID))
if err := b.run(ctx, "chown", "--reference="+b.stateDir, guestsDir, vmidDir); err != nil {
// Warn-only: the drill's manual fix is a one-liner and lanresolver degrades visibly.
b.logger.Warn("provision: could not own guests parent dirs to the state-dir owner (F-3)",
"err", err)
}
}
bootPath := filepath.Join(hostDir, bootstrapFile) bootPath := filepath.Join(hostDir, bootstrapFile)
if err := os.WriteFile(bootPath, rendered, 0o600); err != nil { if err := os.WriteFile(bootPath, rendered, 0o600); err != nil {
return Result{}, fmt.Errorf("provision: write bootstrap: %w", err) return Result{}, fmt.Errorf("provision: write bootstrap: %w", err)
+39
View File
@@ -157,6 +157,45 @@ func TestProvision_WritesChownsAndAttaches(t *testing.T) {
// F3: the provisioned customer guest must be set onboot:1 so it auto-starts after a host // F3: the provisioned customer guest must be set onboot:1 so it auto-starts after a host
// reboot/power-cut (the golden bakes onboot:0 as a template). Assert the exact pct invocation. // reboot/power-cut (the golden bakes onboot:0 as a template). Assert the exact pct invocation.
// F-3 (DRILL-day0-vm-2026-07-12): a ROOT-run provision must chown the guests/ + guests/<vmid>/
// PARENT dirs to the state-dir's owner (chown --reference, NON-recursive — the bootstrap leaf
// stays the mapped guest-root's). A non-root run must NOT issue it (the dirs are already
// agent-created). Companion red-proof: remove the geteuid()==0 chown block in Provision → the
// root case fails (no such invocation recorded); the non-root case alone stays green.
func TestProvision_RootRunOwnsGuestsParents(t *testing.T) {
orig := geteuid
defer func() { geteuid = orig }()
for _, tc := range []struct {
name string
euid int
want bool
}{
{"root run issues the parent chown", 0, true},
{"non-root run does not", 1001, false},
} {
t.Run(tc.name, func(t *testing.T) {
geteuid = func() int { return tc.euid }
dir := t.TempDir()
runner := &recRunner{}
bh := NewBackHalf(&mintMinter{token: "T"}, runner, dir, testLogger())
if _, err := bh.Provision(context.Background(), newInput()); err != nil {
t.Fatalf("provision: %v", err)
}
guestsDir := filepath.Join(dir, "guests")
vmidDir := filepath.Join(guestsDir, "8200")
got := runner.hasExact("chown", "--reference="+dir, guestsDir, vmidDir)
if got != tc.want {
t.Fatalf("parent chown issued=%v want=%v; recorded: %v", got, tc.want, runner.cmds)
}
// Never recursive — the guest-root bootstrap subtree must stay untouched.
if runner.hasExact("chown", "-R", "--reference="+dir, guestsDir, vmidDir) {
t.Fatal("parent chown ran recursively")
}
})
}
}
// Companion red-proof: removing the `b.run(... -onboot 1)` call in Provision makes this FAIL // Companion red-proof: removing the `b.run(... -onboot 1)` call in Provision makes this FAIL
// (no such invocation recorded) — re-applying the call turns it green. // (no such invocation recorded) — re-applying the call turns it green.
func TestProvision_SetsOnbootOne(t *testing.T) { func TestProvision_SetsOnbootOne(t *testing.T) {