Skip to content

fix(pvc): kubelet-owned volume permissions — local-type PVs + fsGroup, no root chowns; harden paid smoke flows#610

Closed
bussyjd wants to merge 7 commits into
mainfrom
research/hermes-configmap-skills-delivery
Closed

fix(pvc): kubelet-owned volume permissions — local-type PVs + fsGroup, no root chowns; harden paid smoke flows#610
bussyjd wants to merge 7 commits into
mainfrom
research/hermes-configmap-skills-delivery

Conversation

@bussyjd

@bussyjd bussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the stack's volume-permission patchwork (provisioner chown -R 1000:1000, root chown init containers, the internal/k8sperm helper, per-workload UID drift) with kubelet-owned permissions: local-path PVs are now provisioned as local-type volumes so the kubelet actually applies pod fsGroup at mount, and every PVC consumer runs non-root with an explicit, documented identity. Also hardens the paid smoke flows (flow-06/08/11/13/14 + libs) and moves the QA runbook to CLI-first surfaces.

Why permissions were "intermittent" for months: Docker Desktop's VirtioFS mounts the data dir with fakeowner — it fakes ownership and skips POSIX permission checks, so every UID mismatch was invisible on macOS k3d. On any real Linux filesystem (native k3s, CI, remote QA boxes) the same workloads hit Permission denied. Green locally, red everywhere that matters. Verified empirically on a live node (see plans/volume-permission-hardening.md).

Ownership model: before → after

flowchart TB
    subgraph BEFORE["Before — chown patchwork (masked by VirtioFS fakeowner on macOS)"]
        P1["local-path provisioner<br/>chown -R 1000:1000 (hardcoded)"] --> V1["hostPath-typed PV<br/>(kubelet IGNORES fsGroup)"]
        V1 --> H1["Hermes UID 10000<br/>root chown init re-owns /data<br/>EVERY start"]
        V1 --> A1["Spawned agents UID 10000<br/>NO chown init → stuck<br/>Provisioning on Linux"]
        V1 --> B1["x402-buyer UID 65532<br/>nothing → EACCES on<br/>consumed.json (Linux)"]
        S1["CLI sync chowns tree to<br/>HOST uid (501 on macOS)"] -.->|flapping| V1
    end

    subgraph AFTER["After — kubelet-owned (this PR)"]
        P2["provisioner: mkdir -m 0770 only<br/>StorageClass defaultVolumeType: local"] --> V2["local-typed PV<br/>kubelet applies fsGroup at mount"]
        V2 --> H2["Hermes + spawned agents<br/>UID/GID 1000, fsGroup 1000,<br/>fsGroupChangePolicy: Always<br/>(host-seeded editable .hermes state)"]
        V2 --> B2["litellm pod fsGroup 65532<br/>buyer container UID 1000<br/>(legacy hostPath-PV state compat)"]
        N2["no root containers anywhere<br/>internal/k8sperm deleted"]
    end

    BEFORE ==> AFTER
Loading

Fresh agent-home lifecycle under the new model

sequenceDiagram
    participant CLI as obol CLI (host)
    participant Prov as local-path provisioner
    participant Kub as kubelet
    participant Pod as Hermes pod (UID 1000)

    CLI->>CLI: SeedHostFiles — SOUL.md, skills,<br/>.no-bundled-skills into $DATA_DIR
    Pod->>Prov: PVC bound (WaitForFirstConsumer)
    Prov->>Prov: mkdir -m 0770 (no chown)
    Note over Prov: PV typed "local", not hostPath
    Pod->>Kub: mount
    Kub->>Kub: fsGroup 1000 ownership walk<br/>(fsGroupChangePolicy: Always —<br/>host re-seeds between mounts)
    Kub->>Pod: volume writable for 1000:1000
    Pod->>Pod: non-root config-seed / profile-seed inits<br/>(replace the root chown init)
    Note over CLI,Pod: config change → checksum/hermes-config<br/>annotation rolls the pod
Loading

⚠️ Breaking: clusters created on ≤ v0.10.0-rc12

PV specs are immutable — existing clusters keep hostPath-typed PVs where the kubelet skips fsGroup, and their Hermes data is owned 10000:10000 by the removed root init. An in-place obol stack up with this code breaks Hermes and the buyer on those clusters.

flowchart LR
    Q{"Cluster created<br/>on ≤ rc12?"} -->|no| OK["nothing to do —<br/>fresh local-type PVs"]
    Q -->|yes| R["recreate (supported path):<br/>wallet backup → down → purge -f →<br/>init → up → wallet restore"]
    Q -->|"yes, must keep cluster"| E["k3d escape hatch:<br/>docker exec node chown -R 1000:1000<br/>over the PV backing dirs + restart pods"]
Loading

Full instructions: plans/volume-permission-hardening.md § Upgrading from ≤ v0.10.0-rc12 and the new troubleshooting entry.

Changes

Permission model

  • local-path.yaml: StorageClass defaultVolumeType: local; setup hook reduced to mkdir -m 0770.
  • hermes.go + agent_render.go: UID/GID 10000 → 1000, fsGroup: 1000, fsGroupChangePolicy: Always, root chown inits removed, non-root config-seed/profile-seed inits, checksum/hermes-config annotation rolls pods on config change. Host-side ownership repair now targets the container UID (1000) on both backends.
  • llm.yaml: litellm pod fsGroup 65532; buyer keeps container UID/GID 1000 — pre-v0.10.0 state PVs hold consumed.json written 0600 by UID 1000, a 65532 sidecar crashloops on load state and takes every paid/* route down.
  • internal/k8sperm deleted; tests pin every contract (embed_local_path_test, embed_buyer_state_test, agent_render_test, hermes_test, stack_test).

Paid-route availability (cross-review fix)

  • Reloader annotation scoped to litellm-config only. The controller rewrites the buyer ConfigMaps on every buy/top-up/auto-refill/tombstone; with strategy: Recreate + 1 replica the broad annotation bounced the entire inference gateway (all Hermes traffic, in-flight SSE) on every purchase event — inverting CLAUDE.md pitfall 7. The buyer hot-reloads via /admin/reload.

Smoke flows

  • flow-06 JSON offer path, flow-08 buy retry + rollout settling, flow-11 register block (Ready poll aligned to flow-14's 300s for pitfall-13 RPC throttling), Bob wallet seeding, PurchaseRequest readiness gates, qwen36-deep targeting.
  • lib.sh reset: stack down --yesfeat: agent purchase type within hermes #614's flow-16 leaves a live offer, and the non-TTY safety gate otherwise refuses graceful teardown on every release-smoke run.

Docs

  • obol-stack-dev runbook → CLI-first QA; troubleshooting entry for the upgrade breakage; plans/volume-permission-hardening.md rewritten with the fakeowner root cause, empirical validation, upgrade section, and a multi-node roadmap (host-FS coupling analysis, options ladder, decisions that bind today).

Compatibility with #614

git merge-tree of this branch × #614 is clean, and the union was built and tested: Hermes v2026.6.5, the buyer 04bebbc pin, flow-16, and this PR's securityContext changes all survive. The stack down --yes and Reloader scoping above are the two semantic interactions the cross-review surfaced.

Post-merge follow-up (shared with #614): the embedded serviceoffer-controller pin predates this PR — released clusters render sub-agents with the old UID-10000 model until the controller is rebuilt from the merge commit and repinned (tracked in embed_image_pin_test.go).

Validation

History

This branch first carried a ConfigMap-delivered config+skills experiment (the original PR title); #615 — merged into this branch — reverted that in favor of host-seeded files + kubelet-owned permissions after the editability tradeoff proved wrong for operator workflows. The net diff contains no ConfigMap delivery; the experiment's useful remnants (checksum-roll annotations, non-root seed inits) survive. The ConfigMap direction returns as option 3 of the multi-node roadmap when single-node stops being an invariant.

🤖 Generated with Claude Code

Ship the agent config.yaml (hermes-config) and the Obol skills tree (new
hermes-skills ConfigMap, a deterministic gzipped tarball in binaryData) to
the Hermes pod, and extract them into the PVC from the init-hermes-data init
container running as the container UID (10000). The host no longer writes the
data PVC, so there is no wrong-owned scaffolding for the root init-hermes-perms
chown to repair, removing the host-write + chown-repair pattern behind the
recurring PVC-ownership bugs.

- embed.SkillsTarball(): deterministic (sorted paths, fixed mode, zero mtime),
  excludes __pycache__/.pyc, so the ConfigMap and the Deployment hash change
  only when the skills themselves change.
- Delete the host-side syncRuntimeFiles / syncObolSkills / removeLegacyHeartbeat
  path; config + skills now arrive via read-only ConfigMap volumes mounted into
  the init container.

Skills still land at /data/.hermes/obol-skills (external_dirs), preserving the
existing read-only consumption semantics.
@OisinKyne
OisinKyne force-pushed the research/hermes-configmap-skills-delivery branch from 4d8102a to 51d58d6 Compare June 9, 2026 11:06
@bussyjd
bussyjd marked this pull request as draft June 9, 2026 13:21
bussyjd and others added 3 commits June 10, 2026 02:27
…with #614

Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).

PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
  Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
  kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
  written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
  `load state`, and takes every paid/<model> route down. On fresh local-type
  PVs the explicit UID is harmless (fsGroup 65532 grants group access).
  embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
  section — supported path is cluster recreation (wallet backup/restore),
  with a documented k3d chown escape hatch. troubleshooting.md gets the
  symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
  patched at runtime without reintroducing the chown machinery this PR
  removes, so it is a documented breaking change instead.

Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
  ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
  controller on every buy, top-up, auto-refill, and tombstone cleanup;
  with strategy Recreate + 1 replica the previous annotation bounced the
  entire inference gateway (all Hermes traffic, in-flight SSE streams) on
  every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
  not the default buy path). The buyer hot-reloads via /admin/reload.
  stack_test.go updated to pin litellm-config-only.

Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
  flow-16 (now last in the single-stack array) intentionally leaves a live
  agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
  refuses, graceful down is silently skipped (`|| true`), and teardown
  degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
  live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).

Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
Records the options ladder for 'nodes joining my cluster' (far-future, not
scheduled) so near-term permission decisions don't foreclose it: the
host-FS-canonical coupling and its four conditions, the agent-homes-only
scope, what breaks on day one of a join (WaitForFirstConsumer + no
nodeSelector in any render), options 0-4 (home-node pattern -> NFS ->
distributed storage -> API-mediated access -> hybrid by data class), join
mechanics (native k3s server + remote agents; macOS stays k3d), and the
decisions that bind today: group-1000 sharing over render-time UID
matching, home-node pinning as a join prerequisite, inputs to API objects
per Remaining Debt.
…-hardening

fix: harden PVC permissions and paid smoke flows
@bussyjd
bussyjd requested a review from OisinKyne June 9, 2026 22:58
@bussyjd
bussyjd marked this pull request as ready for review June 9, 2026 22:58
…t fix

The Shipped Changes bullet still described the pre-c667432 model (buyer
inherits 65532). c667432 restored container-level UID/GID 1000 on the
buyer for legacy hostPath-PV state compat; the bullet now matches llm.yaml
and embed_buyer_state_test.go.
@bussyjd bussyjd changed the title refactor(hermes): deliver config + skills via ConfigMaps, extract in-pod fix(pvc): kubelet-owned volume permissions — local-type PVs + fsGroup, no root chowns; harden paid smoke flows Jun 10, 2026
@bussyjd

bussyjd commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjd bussyjd closed this Jun 10, 2026
@OisinKyne
OisinKyne deleted the research/hermes-configmap-skills-delivery branch July 1, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant