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]>
9.8 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-04 | feat/pihole-declare-state | PiHole's operator-set state is now declared in git — the prerequisite for running more than one replica |
What changed
Phase 1 of making LAN DNS survive a node failure. The operator asked for multi-replica PiHole; measurement showed that cannot be done safely first, because PiHole's important state was not 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 branch closes that; the replica work is phase 2 and is deliberately NOT here.
The undeclared-state inventory, measured not guessed. pihole.toml marks every non-default
setting ### CHANGED, and those that came from the environment ### CHANGED (env). The complete
live list on 2026-10-04:
| Setting | Status |
|---|---|
dns.upstreams, dns.listeningMode, dns.rateLimit.count, misc.dnsmasq_lines |
### CHANGED (env) — already declared in catalog/pihole.yaml |
files.macvendor |
### CHANGED — set by the image itself (container path), not operator state |
webserver.api.pwhash |
### CHANGED — set out of band. Declared NOWHERE. |
So exactly one operator-set value lived only on the PVC. Separately, the adlists were also
undeclared: gravity.db held one list (StevenBlack's hosts) and 72,234 gravity domains, with nothing
in this repo naming it.
apps/wave-0/pihole-bootstrap-secret.sops.yaml(new, SOPS/age). Secretpihole-bootstrap, keywebui-password. Named to the<svc>-bootstrap-secret.sops.yamlconvention sorender.py --emit-wavesplaces it with no renderer change. The value is a 32-char random password generated at authoring time and never printed to a transcript.catalog/pihole.yaml:FTLCONF_webserver_api_passwordsourced from that Secret.webserver.api.passwordtakes CLEARTEXT and FTL hashes it itself — verified in the running image, wherepihole setpasswordissetFTLConfigValue "webserver.api.password", commented upstream "pihole-FTL will automatically hash the password". The PVC'spwhashbecomes a derived artefact.catalog/pihole.yaml: alifecycle.postStarthook that reconciles the declared adlist set intogravity.dbon every container start — adds missing, re-enables disabled, and disables (never deletes) anything enabled but undeclared, runningpihole -gonly when something moved. Same level-triggered idiom, and the same reasoning, ascatalog/qbittorrent.yaml's postStart.apps/wave-0/pihole.yaml,apps/wave-0/kustomization.yaml: re-rendered / secret registered.
Why a postStart hook and not a file. PiHole v6 keeps adlists as DB rows. /etc/pihole/adlists.list
still exists in gravity.sh but is migration-only: its import sits inside migrate_to_database()
behind if [ ! -e "$gravityDBfile" ], so it fires only while creating a database. A ConfigMap
mounted there would be silently ignored on every pod this cluster runs today. There is also no
pihole adlist CLI subcommand, so SQL is the actual interface; FTL ships an embedded shell
(pihole-FTL sqlite3) so nothing had to be added to the image.
The password is kept rather than set empty, deliberately. The admin UI is already behind the
oauth2-proxy sidecar (--allowed-group=platform-admins), so an empty password would have been
simpler. But the pihole Service still publishes port 80 (PiHole direct, bypassing the proxy on
:4180) and only the CiliumNetworkPolicy closes it — and this repo has a documented default-deny
drift history. An unauthenticated admin UI that can rewrite LAN DNS should not rest on one policy
staying correct. Narrowing that Service port is follow-up, not done here.
SENSITIVE PATHS — this PR adds a **/*.sops.yaml, so ci/approval-policy.sh will BLOCK it. It
needs the operator's /approve-override <reason> as a PR review of type "Comment". Not posted by
me: CLAUDE.md section 1 forbids an agent clearing this on its own PR.
What I verified
Live prod, 2026-10-04, read-only except where noted.
- The undeclared-state inventory above — read from
pihole.toml's own### CHANGEDmarkers, with the password value masked. This is the measurement the whole branch rests on. - A fresh
gravity.dbseeds NO adlists. The image's own schema template/etc/.pihole/advanced/Templates/gravity.db.sqlcreates theadlisttable and contains noINSERT INTO adlistat all — grepped in the running image. Confirmed empirically below (0 rows). This is why the adlist declaration is load-bearing and not cosmetic. - The adlists.list path really is migration-only — read the guard in
/opt/pihole/gravity.sh(migrate_to_database(),if [ ! -e "$gravityDBfile" ]). webserver.api.passwordis a real settable key —pihole-FTL --config webserver.api.passwordreturns a masked value, and/usr/local/bin/pihole'ssetpasswordsets exactly that key.- THE RENDERED HOOK ACTUALLY WORKS — all four paths, extracted from
apps/wave-0/pihole.yamland run in the live pod against THROWAWAY COPIES ofgravity.dbin/tmp(productiongravity.dbwas never written, andpihole -gwas stubbed so no 70k-domain download could start):- Steady state →
already matches the declared set - nothing to do. No writes, no rebuild. - True fresh DB built from the image's schema template → 0 adlists before; hook added the declared list and triggered the rebuild. This is the fresh-PVC/new-replica case.
- An undeclared list enabled → disabled it (
enabled=0), left the declared one alone, and did not delete the row. - Declared list present but disabled → re-enabled it.
- Steady state →
- Required tools exist in the image:
bash,expr,grep,printf,sleep,pihole-FTL. - The Secret is genuinely encrypted to the repo's age recipient;
stringData.webui-passwordstartsENC[. The gate'sno-plaintext-secrets (R-SEC-01)L0 check passes. - Schema:
check-jsonschemaagainstschemas/servicedefinition.schema.jsonpasses. - Render determinism: the canonical
render.py --all catalog -o apps --wave-layout --no-image-automationchanged onlyapps/wave-0/pihole.yaml.render-goldenpasses, so the hook's text also satisfiesassert_substitution_safe(no braced dollar expansion — the Flux envsubst contract). - Toolbox gate: rc 3, with
spec-immutability,decisions-log,lint,l0,render-golden,l1,approval-policyall pass. The rc 3 is the two usual environmental skips —gitea-settings(no localSETTINGS_API_TOKEN) andconformanceL2/L3 (CLUSTER_AVAILABLEunset). Not green; reported as rc 3.
What I did NOT verify
- Nothing here has been reconciled. Flux has not applied any of it, so the password has never been set by this mechanism and the hook has never run during a real container start. Everything above is the rendered artifact tested out-of-band, plus the image's own source.
- THE CURRENT ADMIN PASSWORD WILL STOP WORKING, and that is the intent. FTL re-hashes
webserver.api.passwordon start, overwriting the existingpwhash. Whoever knows the present password (nobody, which is why this started) loses it; the declared one replaces it. I have not observed that replacement happen. - Cold-start cost is unmeasured and could matter.
postStartmust complete before the container is reported Running. On a fresh PVC the hook waits forgravity.db(bounded, up to 180s) and then runs a realpihole -g(72k domains, network downloads).apps-wave-0iswait: trueandapps-wave-1dependsOnit, so a slow cold start delays the app tier. The steady-state path exits in about a second (scenario 1), so this is a rebuild/first-boot cost only — but it is not measured, and the stub in testing meanspihole -g's real duration here is unknown. - Whether
pihole -gsucceeds from inside the hook's environment. Its failure is caught and the existinggravity.dbretained, so a failure degrades rather than breaks — untested live. - Concurrency. Nothing serialises the hook against FTL's own scheduled gravity update. SQLite handles the locking, and the hook's writes are three small statements, but no interleaving was exercised.
- MULTI-REPLICA — PHASE 2, NOT STARTED. This branch only removes the blocker. The remaining work,
and it is substantial: a
replicas/placement field on the ServiceDefinition schema (currentlyadditionalProperties: falsewith no such field),render.pyemitting a StatefulSet withvolumeClaimTemplates(the config PVC is ReadWriteOnce, so a 2-replica Deployment cannot work), pod anti-affinity, a PodDisruptionBudget (there is none today), re-rendered goldens, andconformance/**updates — the last making phase 2 sensitive-path as well. The PVC rename a StatefulSet forces will discard the existinggravity.db, which is only safe because of this branch, and should be done only after this one is confirmed working in-cluster. - The real risk the operator named is NOT fixed by phase 2 either. Replicas address node
failure. LAN DNS still depends on the cluster as a whole — etcd quorum, Cilium, Longhorn, a bad
reconcile. Quantified on 2026-10-04: with
tolerationSeconds=300plus node-NotReady detection plus a Longhorn RWO force-detach, a failure of the node running the pod is roughly 6-10 minutes of no LAN DNS; a failure of the node holding thepihole-dns-*L2 lease (currently a different node — lease on k3s-prod-2, pod on k3s-prod-1) is tens of seconds. An out-of-cluster secondary resolver carrying the sameaddress=lines is the only option that removes the dependency, and it was considered and not chosen. - Port 80 on the
piholeService is still published and still closed only by policy. Not narrowed.