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:
### 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 setpasswordissetFTLConfigValue 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):
Steady state → already matches the declared set - nothing to do. No writes, no rebuild.
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.
Undeclared list enabled → disabled it, left the declared one alone, didn't delete the row.
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 onlyapps/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.
**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)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.tomlmarks every non-default setting### CHANGED, and env-sourced ones### CHANGED (env). The complete live list:dns.upstreams,dns.listeningMode,dns.rateLimit.count,misc.dnsmasq_lines### CHANGED (env)— already declaredfiles.macvendor### CHANGED— set by the image, not operator statewebserver.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) feedingFTLCONF_webserver_api_password.webserver.api.passwordtakes cleartext and FTL hashes it itself — verified in the image, wherepihole setpasswordissetFTLConfigValueon that key. The PVC'spwhashbecomes derived state. Value is a 32-char random password, never printed to a transcript.lifecycle.postStarthook that reconciles the declared adlist set intogravity.db: adds missing, re-enables disabled, disables (never deletes) anything undeclared, and runspihole -gonly when something moved. Same level-triggered idiom ascatalog/qbittorrent.yaml.Why a hook and not a file: v6 keeps adlists as DB rows.
/etc/pihole/adlists.liststill exists ingravity.shbut its import is insidemigrate_to_database()behindif [ ! -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.yamland run in the live pod against throwaway copies ofgravity.dbin/tmp(production never written;pihole -gstubbed):already matches the declared set - nothing to do. No writes, no rebuild.Plus: the Secret is genuinely
ENC[-encrypted to the repo's age recipient and the gate'sno-plaintext-secrets (R-SEC-01)passes;check-jsonschemapasses; the canonical render changed onlyapps/wave-0/pihole.yaml.Gate
rc 3 —
spec-immutability,decisions-log,lint,l0,render-golden,l1,approval-policyall pass; the rc 3 is the two usual environmental skips (gitea-settingsno local token,conformanceL2/L3 noCLUSTER_AVAILABLE). Not green; reported as rc 3.🔒 Needs an operator override
Adds a
**/*.sops.yaml— a sensitive path — soapproval-policywill block. Not posted by me (CLAUDE.md §1). Please post as a PR review of type "Comment":Read before merging
pwhash.postStartmust finish before the container reports Running. On a fresh PVC it waits forgravity.db(bounded, ≤180s) then runs a realpihole -g.apps-wave-0iswait: trueand wave 1dependsOnit, so a slow cold start delays the app tier. Steady state exits in ~1s, so this is a first-boot cost only — but the realpihole -gduration here is unknown (it was stubbed in testing).Phase 2, for context
Multi-replica needs: a
replicas/placement field on the SD schema (additionalProperties: false, no such field today),render.pyemitting a StatefulSet withvolumeClaimTemplates(the config PVC is ReadWriteOnce — a 2-replica Deployment cannot work), anti-affinity, a PDB (none today), re-rendered goldens, andconformance/**updates. The PVC rename a StatefulSet forces discards the existinggravity.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=300plus 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 thepihole-dns-*L2 lease (currently a different node) is tens of seconds. Only an out-of-cluster secondary removes the dependency.🤖 Generated with Claude Code