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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ""},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user