feat(pihole): declare the admin password and adlists in git #44

Merged
jakman85 merged 1 commits from feat/pihole-declare-state into main 2026-10-04 06:14:47 +00:00
Owner

Phase 1 of making LAN DNS survive a node failure. You asked for multi-replica Pi-hole; measurement showed that can't be done safely first, because Pi-hole's important state wasn't reproducible from git. A second replica with a fresh PVC would have come up serving DNS and blocking nothing, with a different admin password — answers varying by which pod replied. This closes that. Multi-replica is phase 2 and deliberately not here.

The inventory, measured not guessed

pihole.toml marks every non-default setting ### CHANGED, and env-sourced ones ### CHANGED (env). The complete live list:

Setting Status
dns.upstreams, dns.listeningMode, dns.rateLimit.count, misc.dnsmasq_lines ### CHANGED (env) — already declared
files.macvendor ### CHANGED — set by the image, not operator state
webserver.api.pwhash ### CHANGED — out of band. Declared NOWHERE.

Exactly one operator-set value lived only on the PVC — which is why nobody could find the password. Separately the adlists were undeclared: one list, 72,234 gravity domains, named nowhere in this repo.

What this does

  • apps/wave-0/pihole-bootstrap-secret.sops.yaml (new, SOPS/age) feeding FTLCONF_webserver_api_password. webserver.api.password takes cleartext and FTL hashes it itself — verified in the image, where pihole setpassword is setFTLConfigValue on that key. The PVC's pwhash becomes derived state. Value is a 32-char random password, never printed to a transcript.
  • A lifecycle.postStart hook that reconciles the declared adlist set into gravity.db: adds missing, re-enables disabled, disables (never deletes) anything undeclared, and runs pihole -g only when something moved. Same level-triggered idiom as catalog/qbittorrent.yaml.

Why a hook and not a file: v6 keeps adlists as DB rows. /etc/pihole/adlists.list still exists in gravity.sh but its import is inside migrate_to_database() behind if [ ! -e "$gravityDBfile" ] — migration-only. A ConfigMap mounted there would be silently ignored on every pod you run today.

Verified — the hook actually works

The rendered script was extracted from apps/wave-0/pihole.yaml and run in the live pod against throwaway copies of gravity.db in /tmp (production never written; pihole -g stubbed):

  1. Steady state → already matches the declared set - nothing to do. No writes, no rebuild.
  2. True fresh DB from the image's own schema template → 0 adlists before; hook seeded the declared list and triggered the rebuild. This is the fresh-PVC/new-replica case, and it's why the declaration is load-bearing rather than cosmetic.
  3. Undeclared list enabled → disabled it, left the declared one alone, didn't delete the row.
  4. Declared list disabled → re-enabled it.

Plus: the Secret is genuinely ENC[-encrypted to the repo's age recipient and the gate's no-plaintext-secrets (R-SEC-01) passes; check-jsonschema passes; the canonical render changed only apps/wave-0/pihole.yaml.

Gate

rc 3 — spec-immutability, decisions-log, lint, l0, render-golden, l1, approval-policy all pass; the rc 3 is the two usual environmental skips (gitea-settings no local token, conformance L2/L3 no CLUSTER_AVAILABLE). Not green; reported as rc 3.

🔒 Needs an operator override

Adds a **/*.sops.yaml — a sensitive path — so approval-policy will block. Not posted by me (CLAUDE.md §1). Please post as a PR review of type "Comment":

/approve-override <reason>

Read before merging

  • Your current admin password will stop working, and that's the intent. FTL re-hashes from the declared value on start, overwriting the existing pwhash.
  • Cold-start cost is unmeasured. postStart must finish before the container reports Running. On a fresh PVC it waits for gravity.db (bounded, ≤180s) then runs a real pihole -g. apps-wave-0 is wait: true and wave 1 dependsOn it, so a slow cold start delays the app tier. Steady state exits in ~1s, so this is a first-boot cost only — but the real pihole -g duration here is unknown (it was stubbed in testing).
  • Nothing has been reconciled yet, so none of this has run during a real container start.

Phase 2, for context

Multi-replica needs: a replicas/placement field on the SD schema (additionalProperties: false, no such field today), render.py emitting a StatefulSet with volumeClaimTemplates (the config PVC is ReadWriteOnce — a 2-replica Deployment cannot work), anti-affinity, a PDB (none today), re-rendered goldens, and conformance/** updates. The PVC rename a StatefulSet forces discards the existing gravity.db — safe only because of this branch, and only after it's confirmed working in-cluster.

And worth keeping in view: replicas fix node failure, not the cluster dependency you actually named. With tolerationSeconds=300 plus NotReady detection plus a Longhorn RWO force-detach, losing the node running the pod is ~6–10 min of no LAN DNS; losing the node holding the pihole-dns-* L2 lease (currently a different node) is tens of seconds. Only an out-of-cluster secondary removes the dependency.

🤖 Generated with Claude Code

**Phase 1 of making LAN DNS survive a node failure.** You asked for multi-replica Pi-hole; measurement showed that can't be done safely first, because Pi-hole's important state wasn't reproducible from git. **A second replica with a fresh PVC would have come up serving DNS and blocking nothing, with a different admin password** — answers varying by which pod replied. This closes that. Multi-replica is phase 2 and deliberately **not** here. ### The inventory, measured not guessed `pihole.toml` marks every non-default setting `### CHANGED`, and env-sourced ones `### CHANGED (env)`. The complete live list: | Setting | Status | |---|---| | `dns.upstreams`, `dns.listeningMode`, `dns.rateLimit.count`, `misc.dnsmasq_lines` | `### CHANGED (env)` — already declared | | `files.macvendor` | `### CHANGED` — set by the image, not operator state | | `webserver.api.pwhash` | **`### CHANGED` — out of band. Declared NOWHERE.** | Exactly **one** operator-set value lived only on the PVC — which is why nobody could find the password. Separately the **adlists** were undeclared: one list, 72,234 gravity domains, named nowhere in this repo. ### What this does - **`apps/wave-0/pihole-bootstrap-secret.sops.yaml`** (new, SOPS/age) feeding `FTLCONF_webserver_api_password`. `webserver.api.password` takes cleartext and FTL hashes it itself — verified in the image, where `pihole setpassword` *is* `setFTLConfigValue` on that key. The PVC's `pwhash` becomes derived state. Value is a 32-char random password, never printed to a transcript. - **A `lifecycle.postStart` hook** that *reconciles* the declared adlist set into `gravity.db`: adds missing, re-enables disabled, **disables** (never deletes) anything undeclared, and runs `pihole -g` only when something moved. Same level-triggered idiom as `catalog/qbittorrent.yaml`. **Why a hook and not a file:** v6 keeps adlists as DB rows. `/etc/pihole/adlists.list` still exists in `gravity.sh` but its import is inside `migrate_to_database()` behind `if [ ! -e "$gravityDBfile" ]` — migration-only. A ConfigMap mounted there would be **silently ignored** on every pod you run today. ### Verified — the hook actually works The **rendered** script was extracted from `apps/wave-0/pihole.yaml` and run in the live pod against **throwaway copies** of `gravity.db` in `/tmp` (production never written; `pihole -g` stubbed): 1. **Steady state** → `already matches the declared set - nothing to do`. No writes, no rebuild. 2. **True fresh DB from the image's own schema template** → **0 adlists** before; hook seeded the declared list and triggered the rebuild. *This is the fresh-PVC/new-replica case, and it's why the declaration is load-bearing rather than cosmetic.* 3. **Undeclared list enabled** → disabled it, left the declared one alone, didn't delete the row. 4. **Declared list disabled** → re-enabled it. Plus: the Secret is genuinely `ENC[`-encrypted to the repo's age recipient and the gate's `no-plaintext-secrets (R-SEC-01)` passes; `check-jsonschema` passes; the canonical render changed **only** `apps/wave-0/pihole.yaml`. ### Gate rc **3** — `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` all **pass**; the rc 3 is the two usual environmental skips (`gitea-settings` no local token, `conformance` L2/L3 no `CLUSTER_AVAILABLE`). Not green; reported as rc 3. ### 🔒 Needs an operator override Adds a `**/*.sops.yaml` — a sensitive path — so `approval-policy` will block. Not posted by me (CLAUDE.md §1). Please post as a PR **review** of type "Comment": ``` /approve-override <reason> ``` ### Read before merging - **Your current admin password will stop working, and that's the intent.** FTL re-hashes from the declared value on start, overwriting the existing `pwhash`. - **Cold-start cost is unmeasured.** `postStart` must finish before the container reports Running. On a fresh PVC it waits for `gravity.db` (bounded, ≤180s) then runs a real `pihole -g`. `apps-wave-0` is `wait: true` and wave 1 `dependsOn` it, so a slow cold start **delays the app tier**. Steady state exits in ~1s, so this is a first-boot cost only — but the real `pihole -g` duration here is unknown (it was stubbed in testing). - **Nothing has been reconciled yet**, so none of this has run during a real container start. ### Phase 2, for context Multi-replica needs: a `replicas`/placement field on the SD schema (`additionalProperties: false`, no such field today), `render.py` emitting a **StatefulSet** with `volumeClaimTemplates` (the config PVC is **ReadWriteOnce** — a 2-replica Deployment cannot work), anti-affinity, a PDB (none today), re-rendered goldens, and `conformance/**` updates. The PVC rename a StatefulSet forces discards the existing `gravity.db` — safe *only* because of this branch, and only after it's confirmed working in-cluster. And worth keeping in view: **replicas fix node failure, not the cluster dependency you actually named.** With `tolerationSeconds=300` plus NotReady detection plus a Longhorn RWO force-detach, losing the node running the pod is ~**6–10 min of no LAN DNS**; losing the node holding the `pihole-dns-*` L2 lease (currently a *different* node) is tens of seconds. Only an out-of-cluster secondary removes the dependency. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-10-04 06:08:12 +00:00
feat(pihole): declare the admin password and adlists in git
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
3b1d6ae115
Prerequisite for running more than one PiHole replica. A second replica with a
fresh PVC would have come up serving DNS and blocking nothing, with a different
admin password -- answers varying by which pod replied.

pihole.toml's own ### CHANGED markers gave the exact inventory: four settings
already came from env (declared), one came from the image, and exactly ONE
operator-set value lived only on the PVC -- webserver.api.pwhash, a one-way hash
nobody could recover. Separately the adlists were undeclared: one list and 72,234
gravity domains that nothing in this repo named.

Declares both:
- A SOPS secret feeding FTLCONF_webserver_api_password. webserver.api.password
  takes cleartext and FTL hashes it itself (verified: pihole setpassword IS
  setFTLConfigValue on that key), so the PVC's pwhash becomes derived state.
- A level-triggered postStart hook that reconciles the declared adlist set into
  gravity.db -- adds, re-enables, and disables (never deletes) undeclared lists,
  running pihole -g only when something moved. A file would not work: v6's
  adlists.list import is migration-only, guarded on the DB not existing.

The rendered hook was run in the live pod against throwaway copies of gravity.db,
exercising all four paths. A fresh DB built from the image's own schema template
has ZERO adlists, confirming this is load-bearing rather than cosmetic.

Password kept rather than set empty: the pihole Service still publishes port 80
past the oauth2-proxy, closed only by policy, and default-deny has drifted here
before.

Multi-replica is phase 2 and deliberately not in this branch -- it needs a schema
field, a StatefulSet (the config PVC is RWO), anti-affinity and a PDB.

Adds a *.sops.yaml -- a sensitive path -- so approval-policy will block pending
an operator /approve-override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jakman85 approved these changes 2026-10-04 06:12:47 +00:00
jakman85 scheduled this pull request to auto merge when all checks succeed 2026-10-04 06:13:05 +00:00
jakman85 merged commit 17f0c11403 into main 2026-10-04 06:14:47 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#44