diff --git a/CHANGELOG.md b/CHANGELOG.md index 1083e7a..f96393b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` 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) Fixes the findings CAMPAIGN-3 (`felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`) raised diff --git a/REPORT.md b/REPORT.md index 31cd7e6..dcf34ab 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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`. -**Source of truth:** `felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`. -**Scope:** F12 (CRITICAL), F11/F10/F9 (HIGH/CRITICAL), F2/F1 (LOW), the guest-hook rc255 belt, and the -operator-mandated appliance-mode node self-heal. Controller untouched (MinAgent unchanged). Green gate -(`go build ./... && go vet ./... && go test ./...`) PASS on the build server. +**Date:** 2026-07-12 · **Baselines:** agent v0.85.0 (`bcb8dad`) → **v0.86.0**. +**Source of truth:** the DR-tier-by-default batch spec + `felhom.eu/documentation/audits/DRILL-day0-vm-2026-07-12.md`. +**Scope:** agent half of the batch — the capability `inactive` state (disabled ≠ degraded) and the +F-3 provision parent-dir ownership fix. No escrow/ceremony changes (decision 4: identity-only +escrow is PARKED by policy). Green gate: `go build ./... && go vet ./... && go test ./...` — all +26 packages ok. ## What shipped -- **F12 (CRITICAL) — boot ordering cycle.** Both rendered units drop `After=/Wants=network-online.target` - (`.mount` keeps `_netdev`; `.automount` gets no network relation). `MigrateNetworkUnits` — a general - template-drift reconcile (SHA-256 content compare vs a fresh render of the reconstructed spec; - rewrite + one batched `daemon-reload`; idempotent) — repairs ALREADY-INSTALLED units at startup and - at the head of `EnsureNetworkMount`. -- **F11 — read the right unit** (`netReassertClassify`): decision by host `/proc/mounts` fstype only, - never the automount's own state. **Matrix-corrected:** a rebooted guest inherits neither an idle - trigger NOR an active mount, so the callers re-arm reactively when a running guest is found blind - (`RearmNetworkAutomount` after a failed visibility check). -- **F10 — re-arm for real:** a failed/start-limited unit is `reset-failed` before `enable --now` - (unprivileged `systemctl is-failed` seam + new sudoers verb). -- **F9 — say what you did:** enumerate by marker-owned unit files; one INFO verdict per share. -- **F2/F1 — zero residue:** `RemoveNetworkMount` (and every verify-fail rollback via it) reset-failed's - the pair and `rmdir`s the mountpoint (rmdir-only fail-safe; never `rm -rf`). -- **Hook can never take a guest down:** `runHookPhase` — per-phase recover + hard timeout, always - 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). +1. **Capability `inactive` state** (`internal/capability/probe.go`, `manifest.go`): + - `StatusInactive = "inactive"`, `ReasonInactive = "disabled by configuration"`. + - `Capability.GatedBy` + `Prober.GateActive(gate)` — a HEALTHY probe of a gated capability + whose gate is off is downgraded ok→inactive. Degraded (binary missing / sudo denied) is + NEVER downgraded: an un-migrated pre-v1.15.0 box must not masquerade as deliberately off. + - The 3 `pbsdr-*` entries are gated via the stable name prefix in `Manifest()` (a future + pbsdr-* op is gated automatically; nothing else ever is — pinned by test). + - `Summarize` counts only real degraded; `logCapabilities` logs an inactive count at INFO and + now runs AFTER the pbsdr wiring so the startup snapshot equals the first report's. +2. **`pbsdr.Manager.DRConfigured()`** (`internal/pbsdr/manager.go`): the gate's answer — live + descriptor state (anything but `disabled`), falling back to the persisted converged marker + before the first desired-state fetch (an applied box never flaps inactive across a restart). +3. **F-3** (`internal/provision/backhalf.go`): a root-run provision chowns `guests/` + + `guests//` to the state-dir's owner (`chown --reference`, non-recursive; bootstrap leaf + stays guest-root). Non-root (daemon) runs skip it via the `geteuid` seam. -## 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): -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. -2. **`rmdir /mnt/felhom-drives/*`** (FELHOM_NETMOUNT) — F1: removed-share mountpoint cleanup. rmdir - refuses a non-empty dir (fail-safe; unexpected data is preserved, never `rm -rf`'d). -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. +| Test | Red-proof mutation | Result | +|---|---|---| +| `TestProbe_GateOffHealthyIsInactive` (+ BinaryMissingStaysDegraded, GateOnOrNilIsOK, ExactlyPBSDRGated) | gate branch in `Probe` disabled (`if false`) | inactive case FAILED; degraded case stayed green ✓ | +| `TestDRConfigured_Lifecycle` (unconfigured→applied→restart-marker→disabled) | `DRConfigured` ignores the `disabled` state | disabled case FAILED ✓ | +| `TestProvision_RootRunOwnsGuestsParents` (root issues, non-root doesn't, never `-R`) | F-3 chown block removed | root case FAILED ✓ | -## Tests + red-proofs (all green) +## Live state touched -- Render: no network-online in either unit, `_netdev` present (companion: re-adding the line fails). -- Reconcile: drifted (legacy) unit rewritten once + batched daemon-reload; idempotent (0 rewrites 2nd - pass); spec round-trip exact; foreign units ignored. -- 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. +- **felhom-pve latent F-3 state FIXED live** (pre-existing `guests{,/9201}` root:root 0700 → + chowned to felhom-agent, bootstrap subtree untouched — the drill's proven fix). +- **Peti's host: unreachable** — his F-3 check + agent update stay deferred (publish-train D/E/G). -## 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 -drop-in existed to remove (the campaign recovery used reset-failed+restart, not a drop-in). - -| # | Row | Result | -|---|-----|--------| -| 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…-.automount` with empty `After=`/`Wants=` -overrides + `daemon-reload` strips the ordering until the 0.85 agent migrates the unit at startup. +- `configs/felhom-pbs-apply` was already in this repo; host-install **v1.15.0** (felhom.eu, + 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). +- 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. diff --git a/REUSE.md b/REUSE.md index f26cdf6..65246a0 100644 --- a/REUSE.md +++ b/REUSE.md @@ -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 | | `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 | -| `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` | | `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) | diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 5a4d45a..e465909 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -53,7 +53,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; 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 `). On pre-start it // 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 // 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) { 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 { logger.Error("capability DEGRADED — privileged grant missing (feature impaired until fixed)", "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 // ok→degraded alert. Serve-degraded — a missing grant never blocks startup. 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 // 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. probeAll := func(ctx context.Context) []capability.Status { 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) loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger) 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) desiredSyncer.AddConsumer(pbsdrLoop) // raw desired-state → the pbs_dr block 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)") } + // 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 // verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop diff --git a/internal/capability/manifest.go b/internal/capability/manifest.go index ee27ea8..fde409d 100644 --- a/internal/capability/manifest.go +++ b/internal/capability/manifest.go @@ -12,6 +12,8 @@ // by a sudoers pattern, catching authoring gaps in CI before they ship. package capability +import "strings" + // 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). // Binary is the absolute path the runner invokes; ReprArgs is a CONCRETE argument vector that @@ -24,8 +26,16 @@ type Capability struct { Binary string ReprArgs []string 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): // the OK + newly-CLOSED rows. The SURFACED/DEFERRED rows are deliberately EXCLUDED — they are not // required capabilities: the general `pct exec -- *` (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, // 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 // (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{ // ---- 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}, - {"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-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-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-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-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-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-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false}, + {"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-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, ""}, + {"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, ""}, + {"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-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, ""}, + {"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-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, ""}, // ---- 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-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-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-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false}, + {"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-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-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, ""}, // ---- 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-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-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-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-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-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false, ""}, // ---- 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-rmdir", "removed-share mountpoint cleanup (F1)", "/usr/bin/rmdir", []string{"/mnt/felhom-drives/media"}, 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, ""}, // ---- 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 ---- - {"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-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, 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-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false, ""}, // ---- 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-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}, - {"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, 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-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, ""}, // ---- LAN split-horizon resolver (dnsmasq) ---- - {"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-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "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-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-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, 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-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "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-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-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 // 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-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-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-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-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-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 // 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 // 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 // (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The // 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-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-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-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, true}, + {"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-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-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, ""}, // ---- 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 // 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 + // 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-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-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-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 // 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 // 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-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-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-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false, ""}, } diff --git a/internal/capability/probe.go b/internal/capability/probe.go index 42c341e..30b2d3a 100644 --- a/internal/capability/probe.go +++ b/internal/capability/probe.go @@ -8,19 +8,30 @@ import ( // 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 // 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 { Name string `json:"name"` Feature string `json:"feature"` Critical bool `json:"critical"` - Status string `json:"status"` // "ok" | "degraded" + Status string `json:"status"` // "ok" | "degraded" | "inactive" Reason string `json:"reason,omitempty"` } const ( StatusOK = "ok" 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 // runs `sudo -n -l -- ` LITERALLY — a sudo POLICY LIST that never executes the // 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 // 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 { - Runner Runner - Exists func(path string) bool // nil → os.Stat + Runner Runner + 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, @@ -72,6 +87,11 @@ func (p Prober) Probe(ctx context.Context) []Status { case p.Runner != nil && !p.granted(ctx, c): 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) } return out @@ -85,13 +105,16 @@ func (p Prober) granted(ctx context.Context, c Capability) bool { 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) { total = len(statuses) for _, s := range statuses { - if s.Status == StatusOK { + switch s.Status { + case StatusOK: ok++ - } else { + case StatusDegraded: degraded = append(degraded, s) } } diff --git a/internal/capability/probe_test.go b/internal/capability/probe_test.go index 6c8227f..e7d89c7 100644 --- a/internal/capability/probe_test.go +++ b/internal/capability/probe_test.go @@ -115,3 +115,82 @@ func TestProbe_NilRunnerNoPanic(t *testing.T) { 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) + } + } +} diff --git a/internal/pbsdr/manager.go b/internal/pbsdr/manager.go index 2733a13..5990fb9 100644 --- a/internal/pbsdr/manager.go +++ b/internal/pbsdr/manager.go @@ -125,6 +125,22 @@ func (m *Manager) Status() *hub.PBSDRStatus { 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. func descriptorHash(b *hub.WirePBSDR) string { j, _ := json.Marshal(b) diff --git a/internal/pbsdr/manager_test.go b/internal/pbsdr/manager_test.go index 284732b..7554dda 100644 --- a/internal/pbsdr/manager_test.go +++ b/internal/pbsdr/manager_test.go @@ -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 // pbsDRDescriptor): the exact JSON the hub writes must land in WirePBSDR field-for-field. func TestWireFieldNames(t *testing.T) { diff --git a/internal/provision/backhalf.go b/internal/provision/backhalf.go index e8e8f8d..939df3a 100644 --- a/internal/provision/backhalf.go +++ b/internal/provision/backhalf.go @@ -52,6 +52,9 @@ type BackHalf struct { 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. func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf { 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 { 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// 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) if err := os.WriteFile(bootPath, rendered, 0o600); err != nil { return Result{}, fmt.Errorf("provision: write bootstrap: %w", err) diff --git a/internal/provision/backhalf_test.go b/internal/provision/backhalf_test.go index a351816..5764d11 100644 --- a/internal/provision/backhalf_test.go +++ b/internal/provision/backhalf_test.go @@ -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 // 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// +// 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 // (no such invocation recorded) — re-applying the call turns it green. func TestProvision_SetsOnbootOne(t *testing.T) {