Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f741d744 | ||
|
|
3ee663dbcc | ||
|
|
ad3f56eee8 | ||
|
|
2cb891c05d | ||
|
|
f92707f38c | ||
|
|
22c94effff | ||
|
|
d0db63911a | ||
|
|
81f627167d | ||
|
|
4386d1453b | ||
|
|
874c6703f0 | ||
|
|
bc7095677b | ||
|
|
a0e6ef145f | ||
|
|
c163deaef9 | ||
|
|
642a7752d8 | ||
|
|
989649e64a | ||
|
|
21c88b0dc7 | ||
|
|
b156ee6cfa | ||
|
|
0aa1a0a78a | ||
|
|
422834d4e2 | ||
|
|
1f4552d409 | ||
|
|
c95b1c7121 | ||
|
|
ed6afc3d3f | ||
|
|
254985baa6 | ||
|
|
dce20b7be6 | ||
|
|
028853c87c | ||
|
|
17f0c11403 | ||
|
|
3b1d6ae115 | ||
|
|
939461120a | ||
|
|
fbaeb3e038 | ||
|
|
9a69f6906a | ||
|
|
7ef2af205d | ||
|
|
788eaeae62 | ||
|
|
576e1abb19 | ||
|
|
a1e808e697 | ||
|
|
bcb8931b4f | ||
|
|
0d7f94098b | ||
|
|
5ffd0c98bc | ||
|
|
f0994c11a0 | ||
|
|
b52c8d1af6 | ||
|
|
1677dff568 | ||
|
|
8ccfdab180 | ||
|
|
948875aad3 | ||
|
|
bdfa72b1c7 | ||
|
|
4f16e83eec | ||
|
|
0c7e8dd3ba | ||
|
|
8c12a1e525 | ||
|
|
9babe5768f | ||
|
|
78bf68493d | ||
|
|
8a094c46f7 | ||
|
|
8214642d87 | ||
|
|
58f02fe8b1 | ||
|
|
2983378496 | ||
|
|
9048c2f5e8 | ||
|
|
753c48be24 | ||
|
|
1e71b578d5 | ||
|
|
99560c2d22 | ||
|
|
bce101d03f | ||
|
|
ba79a1b59b | ||
|
|
d44490736c | ||
|
|
fc7ec01628 |
No files matched your search
@@ -4,6 +4,125 @@ Read this before doing anything durable. It is the delivery contract for AI codi
|
|||||||
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
|
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
|
||||||
takes precedence on *what* to work on; this file governs *how* it lands.
|
takes precedence on *what* to work on; this file governs *how* it lands.
|
||||||
|
|
||||||
|
**Start with §0.** It is first because it is the rule you can break before you have read
|
||||||
|
anything else: the very first `git checkout -b` in the wrong directory has already done the
|
||||||
|
damage.
|
||||||
|
|
||||||
|
## 0. Work in your OWN workspace. Never in the shared checkout.
|
||||||
|
|
||||||
|
Before you create a branch, edit a file, or run `git` anything: **make yourself a git
|
||||||
|
worktree outside the repository root, and do all of your work there.** One workspace per
|
||||||
|
agent run, no exceptions.
|
||||||
|
|
||||||
|
# from the shared checkout, ONCE, at the start of your run
|
||||||
|
git fetch origin main
|
||||||
|
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||||
|
cd /path/outside/the/repo/wt-<slug> # everything after this happens HERE
|
||||||
|
|
||||||
|
`/path/outside/the/repo` means outside this repository's tree — your session scratchpad,
|
||||||
|
or a sibling like `/var/home/keith/source/wt-<slug>`. Branch from **`origin/main`**, never
|
||||||
|
from `main` and never from `HEAD`; see "stale base" below.
|
||||||
|
|
||||||
|
**The shared checkout is `/var/home/keith/source/home-network-k3s-impl`** — the operator's
|
||||||
|
working copy, the one you are probably started in. In it, do **not**:
|
||||||
|
|
||||||
|
- **`git checkout` / `git switch` / `git checkout -b`.** Its HEAD is shared state; moving
|
||||||
|
it moves the tree out from under whoever else is using it.
|
||||||
|
- **edit, create or delete a file.** Including "just one doc", including a file you intend
|
||||||
|
to move later.
|
||||||
|
- **`git add` / `git commit` / `git stash` / `git restore`.** `git add -A` there stages
|
||||||
|
whatever another agent left lying around; `git stash` *removes* their uncommitted work
|
||||||
|
into a place they will not look.
|
||||||
|
- **`git worktree remove` anything you did not create.**
|
||||||
|
|
||||||
|
Read-only commands there are fine, and the one write you do need is creating your own
|
||||||
|
worktree.
|
||||||
|
|
||||||
|
### Why — measured in the shared checkout, 2026-10-05
|
||||||
|
|
||||||
|
| What was measured | Value |
|
||||||
|
|---|---|
|
||||||
|
| `checkout: moving` entries in its reflog | **107** — the tree has been yanked between branches 107 times |
|
||||||
|
| the branch it was sitting on | `feat/pm-agent-issue-to-pr` — **not `main`** |
|
||||||
|
| uncommitted trees left in it by other runs | **2** (`infra/agent/`, `platform/pm-agent/`), mtimes five minutes apart the same evening |
|
||||||
|
| its local `main` vs `origin/main` | **two commits behind** (`4386d14` vs `22c94ef`) |
|
||||||
|
| registered worktrees | **42**, of which **31** are on branches already merged to `main` |
|
||||||
|
| gate scripts carrying an explicit `.claude/` exclusion | **9** |
|
||||||
|
|
||||||
|
Each row is a distinct way the shared checkout damages work:
|
||||||
|
|
||||||
|
- **Dirty tree, foreign files.** Two other runs' uncommitted directories were sitting in
|
||||||
|
it. An agent that edits there and runs `git add -A` ships someone else's half-finished
|
||||||
|
work inside its own PR, and the PR's author has no idea what those files are. This is not
|
||||||
|
hypothetical and it is not historical: **eleven minutes after those two directories were
|
||||||
|
measured, while this very section was being written, another run committed them** —
|
||||||
|
`5334abe feat(pm-agent): ...`, 1,000+ lines across `infra/agent/`, `clusters/`, `catalog/`
|
||||||
|
and `apps/` — onto the shared checkout's checked-out branch, moving its HEAD under anyone
|
||||||
|
reading it. Had this rule's author been working in that tree rather than in a worktree,
|
||||||
|
that commit and this one would have collided.
|
||||||
|
- **Stale base.** Its `main` was two commits behind `origin/main`, so
|
||||||
|
`git checkout -b x main` there branches off a tree that is already not what CI will merge
|
||||||
|
into — the gate you run locally proves a base nobody else has. Worse, it was not even *on*
|
||||||
|
`main`: a bare `git checkout -b x` would have branched off `feat/pm-agent-issue-to-pr` and
|
||||||
|
silently inherited that work into your PR. `git fetch` first, and name `origin/main`
|
||||||
|
explicitly as the start point.
|
||||||
|
- **Shared HEAD.** 107 branch switches in one tree is 107 chances to switch it while
|
||||||
|
another agent is mid-edit. The loser's changes are either carried onto the wrong branch
|
||||||
|
or lost to a checkout conflict — and the symptom surfaces in *their* session, not yours.
|
||||||
|
|
||||||
|
### Put the workspace OUTSIDE the repo root
|
||||||
|
|
||||||
|
A worktree *inside* the tree (`.claude/worktrees/agent-*/` — which is where Claude Code's
|
||||||
|
own default puts it) is a full second copy of the repo that every tree-walking check then
|
||||||
|
finds. Measured 2026-09-12: `find`-based discovery reported **the same manifest under test
|
||||||
|
once per worktree, up to 18 times**, and the L0 check comparing that listing against the
|
||||||
|
tree failed on paths that are not authored surface at all
|
||||||
|
(`platform/monitoring/tests/run.sh:115-123`). Nine gate scripts now carry an explicit
|
||||||
|
`.claude/` prune for this reason — which means the hazard is *worked around*, not fixed,
|
||||||
|
and the next check that walks the tree will hit it again. Keep the copy out of the tree and
|
||||||
|
none of that applies.
|
||||||
|
|
||||||
|
### Clean up — and expect not to be able to
|
||||||
|
|
||||||
|
31 of the 42 registered worktrees are on branches already merged to `main`. A registered
|
||||||
|
worktree **pins its branch**: while it exists, that branch cannot be deleted or checked out
|
||||||
|
anywhere else. So a left-behind workspace is a lock on a branch name, and the cost lands on
|
||||||
|
whoever next needs it.
|
||||||
|
|
||||||
|
Remove yours when you are done:
|
||||||
|
|
||||||
|
git worktree remove /path/outside/the/repo/wt-<slug>
|
||||||
|
|
||||||
|
Measured 2026-10-05: on a worktree **you created yourself, in the session scratchpad**, that
|
||||||
|
command **succeeds** — rc 0, no prompt, with or without a branch checked out. The "worktree
|
||||||
|
removal is sandbox-blocked" note in circulation is about the `.claude/worktrees/agent-*` ones
|
||||||
|
belonging to *other* sessions, some of which are additionally `locked` on git's own terms. So:
|
||||||
|
remove yours, and only yours. If removal is refused, say so in your handoff and leave the
|
||||||
|
worktree registered — do **not** `rm -rf` the directory, which leaves a registered worktree
|
||||||
|
pointing at nothing and still holding the branch.
|
||||||
|
|
||||||
|
If someone *else's* stale worktree is holding the branch you need, do not try to reclaim the
|
||||||
|
branch name. Push a `rework/<pr-number>` branch to the PR's refspec instead.
|
||||||
|
|
||||||
|
### If you are already standing in the shared checkout with edits
|
||||||
|
|
||||||
|
Stop. Do not `git stash` (that moves other agents' work too). Create your worktree as
|
||||||
|
above, redo the edits there, and leave the shared checkout's HEAD and working tree exactly
|
||||||
|
as you found them.
|
||||||
|
|
||||||
|
### Gating from a worktree
|
||||||
|
|
||||||
|
Three known breakages, all with workarounds, all required every run: see §3, "Running the
|
||||||
|
gate from a worktree".
|
||||||
|
|
||||||
|
### If you are writing a brief for another agent
|
||||||
|
|
||||||
|
The brief must name **its own explicit branch** and tell it to create **its own worktree**
|
||||||
|
from `origin/main`. "Work on the current branch" and "continue in the checkout" are how
|
||||||
|
branch sprawl and cross-contamination got here: an agent given no workspace uses the one it
|
||||||
|
is started in, which is the shared one. Also carry §3's three gate workarounds into the
|
||||||
|
brief — an agent that cannot gate will report rc 3 as green.
|
||||||
|
|
||||||
## 1. Never push to `main`. Ever.
|
## 1. Never push to `main`. Ever.
|
||||||
|
|
||||||
Every durable change reaches `main` exactly one way:
|
Every durable change reaches `main` exactly one way:
|
||||||
@@ -36,6 +155,36 @@ sensitive path, so the policy cannot be widened or narrowed without a human.
|
|||||||
|
|
||||||
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
||||||
missing credential is a lock-out, here a skip would be a bypass.
|
missing credential is a lock-out, here a skip would be a bypass.
|
||||||
|
|
||||||
|
**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human
|
||||||
|
who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the
|
||||||
|
only collaborator — so a sensitive-path PR the operator opened could never be merged, *including
|
||||||
|
the PR that would fix that.* An administrator may therefore waive the review by posting this on
|
||||||
|
the PR:
|
||||||
|
|
||||||
|
/approve-override <reason>
|
||||||
|
|
||||||
|
**Post it as a PR *review* of type "Comment", not in the plain comment box.** CI's
|
||||||
|
`SETTINGS_API_TOKEN` is minted `[read:repository, read:admin]`, and `/issues/{n}/comments` sits
|
||||||
|
behind Gitea's *issue* scope — so an override typed into the comment box is **invisible to CI**,
|
||||||
|
and the stage reports it as a missing approval rather than as a permission problem (measured on
|
||||||
|
PR #38, 2026-10-02). `/pulls/{n}/reviews` is readable, so a review body works today. Both sources
|
||||||
|
are accepted and the gate names which it used; the comment box starts working once
|
||||||
|
`infra/gitea/set-ci-token.sh` is re-run to pick up `read:issue`.
|
||||||
|
|
||||||
|
The directive must **start a line**, the **reason is mandatory**, the poster's `admin`/`owner`
|
||||||
|
permission is re-checked **live** when the gate runs, and it must be **newer than the
|
||||||
|
head commit** — push again and you must override again. The overrider, the timestamp and the
|
||||||
|
reason are printed in the gate log and the job summary. For an administrator, "a change to the
|
||||||
|
gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an
|
||||||
|
attributed record of each waiver. Note that the agent account `gitea-admin` also holds `owner`
|
||||||
|
here, so an agent *can* clear this on its own PR — **do not**: posting that directive to unblock
|
||||||
|
your own agent work is exactly the self-approval the stage exists to prevent. Get the operator
|
||||||
|
to post it, or move the change out of the sensitive paths.
|
||||||
|
|
||||||
|
**`required_approvals` on `main` is `0`**, not 1 — the gate is the approver. That drops the
|
||||||
|
approval *count* only: `block_admin_merge_override` and `enable_status_check` both remain `true`,
|
||||||
|
so **a red gate is unmergeable by everyone, administrators included.**
|
||||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
||||||
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||||
|
|
||||||
@@ -53,12 +202,19 @@ Verified against the live Gitea on 2026-09-29:
|
|||||||
| `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word |
|
| `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word |
|
||||||
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
|
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
|
||||||
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
|
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
|
||||||
| Approval required before merge | ✅ `required_approvals: 1` |
|
| Gate cannot be overridden, by anyone | ✅ `block_admin_merge_override: true` |
|
||||||
|
| Human approval required before merge | ⬜ **No — `required_approvals: 0` since 2026-10-01.** The gate is the approver; sensitive paths are gated by `ci/approval-policy.sh` instead. See §1 |
|
||||||
|
|
||||||
All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
|
The first three were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
|
||||||
true. They are true now because each was measured, and the push rejection was provoked rather
|
true. They are true now because each was measured, and the push rejection was provoked rather
|
||||||
than assumed. **Re-measure before relying on any of them** — see §2b.
|
than assumed. **Re-measure before relying on any of them** — see §2b.
|
||||||
|
|
||||||
|
The fourth row deliberately changed on 2026-10-01. `required_approvals: 1` was a **lock-out**, not
|
||||||
|
a protection: with a single collaborator and Gitea refusing author self-approval, nothing could
|
||||||
|
satisfy it (measured on PR #35 — green gate, zero reviews, unmergeable). It also contradicted the
|
||||||
|
model already written into `ci/approval-policy.sh` and `ci/gate.sh`. Rationale and the live-change
|
||||||
|
script: `infra/gitea/allow-admin-self-merge.sh`.
|
||||||
|
|
||||||
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
|
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
|
||||||
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
|
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
|
||||||
|
|
||||||
@@ -110,15 +266,21 @@ Exit codes are a contract — do not flatten them:
|
|||||||
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
|
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
|
||||||
|
|
||||||
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
|
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
|
||||||
beside this one; that single skip is expected and tolerated. Any *other* skip is a
|
beside this one; that single skip is expected and tolerated — and from a worktree it is
|
||||||
real gap in your run.
|
avoidable outright (§3's worktree list, item 3). `gitea-settings` is the other expected
|
||||||
|
skip: it needs `SETTINGS_API_TOKEN`, which exists only in CI, so **a local run cannot
|
||||||
|
return 0** — the honest best local result is rc 3 with `gitea-settings` as the only stage
|
||||||
|
that did not run. Any *further* skip is a real gap in your run. Report the rc and name the
|
||||||
|
skipped stages; never round rc 3 up.
|
||||||
|
|
||||||
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
|
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
|
||||||
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
|
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
|
||||||
|
|
||||||
### Running the gate from a git worktree
|
### Running the gate from a worktree
|
||||||
|
|
||||||
Two unrelated failures, both with known workarounds — put both in any agent brief:
|
This is the **normal** path, not an edge case — §0 requires you to be in a worktree, so
|
||||||
|
every gate run you do hits all of these. Three known breakages, all with
|
||||||
|
workarounds, all of them yours to apply every run — put them in any agent brief:
|
||||||
|
|
||||||
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
|
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
|
||||||
and exists only in the main checkout. Do **not** copy it (it is a credential).
|
and exists only in the main checkout. Do **not** copy it (it is a credential).
|
||||||
@@ -129,10 +291,17 @@ Two unrelated failures, both with known workarounds — put both in any agent br
|
|||||||
the `ENGINE` hook — a shim that intercepts `run` and re-execs
|
the `ENGINE` hook — a shim that intercepts `run` and re-execs
|
||||||
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
|
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
|
||||||
`tracked_files()` is this bug, not a broken tree.
|
`tracked_files()` is this bug, not a broken tree.
|
||||||
|
3. **`spec-immutability` skips** — the skip called tolerated above is also *avoidable*. `dev.sh`
|
||||||
|
derives the sibling spec path from its own `REPO_ROOT`, which in a worktree is your
|
||||||
|
workspace, so `../home-network-k3s` does not resolve and it adds no mount. Mount the spec
|
||||||
|
repo read-only in the same `ENGINE` shim and pass `-e SPEC_REPO=/home-network-k3s`: with
|
||||||
|
both mounts the stage reports `pass`, not SKIPPED, and R-TEST-06 is actually covered
|
||||||
|
(measured — `decisions/2026-10-01-render-selftest-runs-in-gate.md`).
|
||||||
|
|
||||||
## 4. Branch naming
|
## 4. Branch naming
|
||||||
|
|
||||||
Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push**
|
Use `feat/`, `fix/`, `docs/`, `chore/`. **One branch, one workspace, one PR** — if you
|
||||||
|
need a second branch, make a second worktree (§0); do not re-point an existing one. Note that `.gitea/workflows/gate.yml`'s **push**
|
||||||
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
|
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
|
||||||
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
|
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ specification** at [`../home-network-k3s`](../home-network-k3s).
|
|||||||
- **Done = a green, reproducible, requirement-tagged conformance test** — never "merged," never a
|
- **Done = a green, reproducible, requirement-tagged conformance test** — never "merged," never a
|
||||||
manual demo (spec `docs/14`, `agents/definition-of-done.md`).
|
manual demo (spec `docs/14`, `agents/definition-of-done.md`).
|
||||||
- **No work on `main`.** Every change is a feature branch merged into `main` (spec `CONTRIBUTING.md`).
|
- **No work on `main`.** Every change is a feature branch merged into `main` (spec `CONTRIBUTING.md`).
|
||||||
|
- **No work in a shared checkout.** Every agent run gets its own git worktree, outside the repo
|
||||||
|
root, branched from `origin/main`. This checkout is shared mutable state — see
|
||||||
|
[`CLAUDE.md` §0](CLAUDE.md).
|
||||||
- **No plaintext secrets** — SOPS-encrypted only (spec `docs/07`).
|
- **No plaintext secrets** — SOPS-encrypted only (spec `docs/07`).
|
||||||
- **Phase discipline** — only build what the active roadmap phase authorizes (spec `roadmap/phases.md`).
|
- **Phase discipline** — only build what the active roadmap phase authorizes (spec `roadmap/phases.md`).
|
||||||
|
|
||||||
@@ -21,7 +24,8 @@ infra/ node provisioning: OpenTofu (Proxmox VE) + cloud-init + Ansible,
|
|||||||
bootstrap/ one-time cluster bootstrap: Flux install, SOPS/age key, host Gitea
|
bootstrap/ one-time cluster bootstrap: Flux install, SOPS/age key, host Gitea
|
||||||
platform/ platform CONTROLLERS (Flux HelmReleases / operator bases): kyverno, cilium,
|
platform/ platform CONTROLLERS (Flux HelmReleases / operator bases): kyverno, cilium,
|
||||||
longhorn, external-snapshotter, cert-manager, externaldns, gateway, coredns,
|
longhorn, external-snapshotter, cert-manager, externaldns, gateway, coredns,
|
||||||
vpn-egress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
vpn-egress, vpn-ingress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
||||||
|
(vpn-egress = pods OUT via PIA; vpn-ingress = remote clients IN via WireGuard)
|
||||||
platform-config/ platform CONFIGS — the custom resources of the CRDs installed above
|
platform-config/ platform CONFIGS — the custom resources of the CRDs installed above
|
||||||
(ClusterIssuers, Gateway, Kyverno policies, KubeVirt/CDI CRs, Keycloak CRs).
|
(ClusterIssuers, Gateway, Kyverno policies, KubeVirt/CDI CRs, Keycloak CRs).
|
||||||
Split out because CRs and the CRDs they use cannot apply in one pass.
|
Split out because CRs and the CRDs they use cannot apply in one pass.
|
||||||
@@ -94,6 +98,20 @@ The running narrative — decisions, root causes, incidents, follow-ups — is
|
|||||||
|
|
||||||
## Working on this repo
|
## Working on this repo
|
||||||
|
|
||||||
|
**First, get out of this checkout.** It is the operator's working copy and several agents
|
||||||
|
share it; concurrent runs here have overwritten each other's branches and left each other's
|
||||||
|
uncommitted files behind. One workspace per change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin main
|
||||||
|
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||||
|
cd /path/outside/the/repo/wt-<slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
Outside the repo root specifically — a worktree under `.claude/worktrees/` is a second full
|
||||||
|
copy that tree-walking gate checks then count twice. The rule, the measurements behind it, and
|
||||||
|
the three gate workarounds a worktree needs are in [`CLAUDE.md` §0](CLAUDE.md) and §3.
|
||||||
|
|
||||||
Everything the gate needs is baked into the pinned dev container — **do not install the
|
Everything the gate needs is baked into the pinned dev container — **do not install the
|
||||||
toolchain on the host**:
|
toolchain on the host**:
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ spec:
|
|||||||
- --allowed-group=platform-admins
|
- --allowed-group=platform-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://jackett.${BASE_DOMAIN}/oauth2/callback
|
- --redirect-url=https://jackett.${BASE_DOMAIN}/oauth2/callback
|
||||||
|
- --api-route=^/api/
|
||||||
- --cookie-name=_oauth2_proxy_jackett
|
- --cookie-name=_oauth2_proxy_jackett
|
||||||
- --cookie-domain=jackett.${BASE_DOMAIN}
|
- --cookie-domain=jackett.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.${BASE_DOMAIN}
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
@@ -243,6 +244,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): jackett-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: jackett-allow-monitoring-scrape
|
||||||
|
namespace: jackett
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: jackett
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: jackett
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ resources:
|
|||||||
- nextcloud-db-backup-credentials-prod.sops.yaml
|
- nextcloud-db-backup-credentials-prod.sops.yaml
|
||||||
- nextcloud-db-backup-credentials.sops.yaml
|
- nextcloud-db-backup-credentials.sops.yaml
|
||||||
- nextcloud-db.yaml
|
- nextcloud-db.yaml
|
||||||
|
- pihole-bootstrap-secret.sops.yaml
|
||||||
- pihole-dns.yaml
|
- pihole-dns.yaml
|
||||||
- pihole-oauth2-proxy-credentials.sops.yaml
|
- pihole-oauth2-proxy-credentials.sops.yaml
|
||||||
- pihole.yaml
|
- pihole.yaml
|
||||||
- plex-claim-secret.sops.yaml
|
- plex-claim-secret.sops.yaml
|
||||||
|
- plex-remote.yaml
|
||||||
- plex.yaml
|
- plex.yaml
|
||||||
- qbittorrent-bootstrap-secret.sops.yaml
|
- qbittorrent-bootstrap-secret.sops.yaml
|
||||||
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# =================================================================================================
|
||||||
|
# PiHole WEB/API ADMIN PASSWORD — the one piece of PiHole state that was NOT declared anywhere.
|
||||||
|
# =================================================================================================
|
||||||
|
# WHY THIS FILE EXISTS. Measured on live prod 2026-10-04, `pihole.toml` marks every non-default
|
||||||
|
# setting with `### CHANGED`, and names the ones that came from the environment `### CHANGED (env)`.
|
||||||
|
# The complete list was:
|
||||||
|
#
|
||||||
|
# dns.upstreams ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||||
|
# dns.listeningMode ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||||
|
# dns.rateLimit.count ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||||
|
# misc.dnsmasq_lines ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||||
|
# files.macvendor ### CHANGED <- set by the image itself, container path
|
||||||
|
# webserver.api.pwhash ### CHANGED <- SET OUT OF BAND. Declared NOWHERE. This file.
|
||||||
|
#
|
||||||
|
# So exactly ONE operator-set value lived only on the Longhorn PVC: the admin password, as a
|
||||||
|
# one-way BALLOON-SHA256 hash. Nobody could recover it (a hash does not reverse), nothing in git
|
||||||
|
# produced it, and a rebuild from git would have come up with a DIFFERENT password — or none.
|
||||||
|
#
|
||||||
|
# THE REASON THIS BLOCKS HA, which is why it is fixed first. The next step is running more than one
|
||||||
|
# PiHole replica, and a replica with a fresh PVC would come up with its own unrelated password. The
|
||||||
|
# admin UI would then accept or reject the same credential depending on which pod answered. Declaring
|
||||||
|
# it here makes every replica, and every rebuild, identical by construction.
|
||||||
|
#
|
||||||
|
# MECHANISM: `webserver.api.password` is a real, settable FTL config key — verified in the running
|
||||||
|
# image, where `pihole setpassword` is itself just `setFTLConfigValue "webserver.api.password"` with
|
||||||
|
# the comment "pihole-FTL will automatically hash the password". So FTL takes CLEARTEXT on this key
|
||||||
|
# and writes the hash into `webserver.api.pwhash` itself. catalog/pihole.yaml therefore passes this
|
||||||
|
# value as `FTLCONF_webserver_api_password`, and the hash on the PVC becomes a derived artefact
|
||||||
|
# rather than the source of truth.
|
||||||
|
#
|
||||||
|
# DEFENCE IN DEPTH, NOT THE ONLY GATE. The admin UI is already behind the oauth2-proxy sidecar
|
||||||
|
# (`--allowed-group=platform-admins`), so this password is a SECOND gate. It is kept rather than set
|
||||||
|
# empty because the `pihole` Service still publishes port 80 (PiHole direct, bypassing the proxy on
|
||||||
|
# :4180) and only the CiliumNetworkPolicy closes it — and the default-deny policy in this repo has a
|
||||||
|
# documented drift history. An unauthenticated admin UI that can rewrite LAN DNS is not a good thing
|
||||||
|
# to leave resting on one policy staying correct. Narrowing that Service port is tracked follow-up.
|
||||||
|
#
|
||||||
|
# ROTATION: `sops` this file in your own terminal, change the value, commit. Flux re-applies the
|
||||||
|
# Secret, the pod restarts, FTL re-hashes. Do NOT `sops -d` it into a shared terminal or a log
|
||||||
|
# (CLAUDE.md section 6); read it in an editor. The value below was generated randomly at authoring
|
||||||
|
# time and has never been printed to a transcript.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: pihole-bootstrap
|
||||||
|
namespace: pihole
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
#ENC[AES256_GCM,data:TMOuRyJeb5IVn3lZPXY7lgh1KGxu7GY9+WIOLHpMB50JqzmM2tHIaWUWKTt1JtZoX/E5/Y7WCUTnfkJLhRBN2zV2MwWRBSLYVy5CMg7xV8PTLRZJYAVUYS5Trg==,iv:q+DpfT6PQfY5ZuNsmP7Ddl+PDLjSm89fJICAhYoW64k=,tag:axijaO3DaxIZgZ3LeK2hXA==,type:comment]
|
||||||
|
webui-password: ENC[AES256_GCM,data:7FcK/bUW79M46f/MlouAmATKbMnoUTNuMUjtqJ5vpU4=,iv:I8DBFB39uXZV42Ie2IPwSefmMgdKEtiDknfc9Tu7h5E=,tag:L423784UrDU3TvylpHc6KQ==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0MkRSNjZLZlpXYkcvaVJL
|
||||||
|
cjlqb2wrcWREZnZLdGpsTHNPWEpseHlRZnpjCnJVM1FDQ2M2dXlaS1JBZ2k2V1BP
|
||||||
|
Yi9QUDZSMzZsL09pTi93RTdjdGxtY2MKLS0tIDIrQTZIVWdhMGIxSCtlUFN2Mlho
|
||||||
|
SWVybld4VjJyaHRSKzZZU0xsUUV2SEEKbDYHV/qBZVspK7DpBW3vYjRMJ/HmAN+9
|
||||||
|
G9bOlNEp0jAtcNZEn6lpQ9ORgvmIrhJHoBQRLabdMWzNWSZsNn56tA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-10-04T06:01:27Z"
|
||||||
|
mac: ENC[AES256_GCM,data:rhTMTOwLOtHXHgqI5M2ZW/6w+UpLpxBlTk7NoN0wmGSqOcE+OGrXA1DhkZrJweyQT0RSNSSBm/6QMztLP81eepOzdp2rEkwvbQPK/l6K9g16Ww9c1IatS3NQsKEB8AoMDx7jBQ6exXYgggPx3MJsNYbE+WyloQ96U9KPdrecAM8=,iv:n34v2zX9D5xsaR1PFCH94mNMiuRfhUyI2lseUCoqiTA=,tag:9YmN9nIVLXfsGYIb0bUl2w==,type:str]
|
||||||
|
pgp: []
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
version: 3.9.4
|
||||||
+75
-29
@@ -35,34 +35,18 @@ spec:
|
|||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 53
|
port: 53
|
||||||
---
|
---
|
||||||
# PVC pihole-config (R-STATE)
|
# StatefulSet (R-PLAT-02)
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: pihole-config
|
|
||||||
namespace: pihole
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: pihole
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
storageClassName: longhorn
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 2Gi
|
|
||||||
---
|
|
||||||
# Deployment (R-PLAT-02)
|
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
name: pihole
|
name: pihole
|
||||||
namespace: pihole
|
namespace: pihole
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: pihole
|
app.kubernetes.io/name: pihole
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 2
|
||||||
strategy:
|
serviceName: pihole
|
||||||
type: Recreate
|
podManagementPolicy: Parallel
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: pihole
|
app.kubernetes.io/name: pihole
|
||||||
@@ -89,10 +73,22 @@ spec:
|
|||||||
- name: FTLCONF_dns_rateLimit_count
|
- name: FTLCONF_dns_rateLimit_count
|
||||||
value: '0'
|
value: '0'
|
||||||
- name: FTLCONF_misc_dnsmasq_lines
|
- name: FTLCONF_misc_dnsmasq_lines
|
||||||
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}
|
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
|
||||||
|
- name: FTLCONF_webserver_api_password
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: pihole-bootstrap
|
||||||
|
key: webui-password
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /data/config
|
mountPath: /etc/pihole
|
||||||
|
lifecycle:
|
||||||
|
postStart:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
@@ -163,10 +159,39 @@ spec:
|
|||||||
port: http
|
port: http
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 15
|
periodSeconds: 15
|
||||||
volumes:
|
affinity:
|
||||||
- name: config
|
podAntiAffinity:
|
||||||
persistentVolumeClaim:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
claimName: pihole-config
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: config
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: longhorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
|
---
|
||||||
|
# PodDisruptionBudget — keep one replica through a drain (availability.replicas)
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: pihole
|
||||||
|
namespace: pihole
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
---
|
---
|
||||||
# Service
|
# Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -207,6 +232,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): pihole-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: pihole-allow-monitoring-scrape
|
||||||
|
namespace: pihole
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: pihole
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
@@ -269,11 +315,11 @@ spec:
|
|||||||
- name: pihole.slis
|
- name: pihole.slis
|
||||||
rules:
|
rules:
|
||||||
- alert: pihole-down
|
- alert: pihole-down
|
||||||
expr: kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"} == 0 or absent(kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"})
|
expr: kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"} == 0 or absent(kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"})
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
service: pihole
|
service: pihole
|
||||||
annotations:
|
annotations:
|
||||||
summary: pihole is down
|
summary: pihole is down
|
||||||
description: 'pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'
|
description: 'pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready).'
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
# =================================================================================================
|
||||||
|
# PLEX REMOTE ACCESS — raw tcp/32400 north-south exposure, and the policy that admits it.
|
||||||
|
# =================================================================================================
|
||||||
|
# HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition
|
||||||
|
# and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same
|
||||||
|
# mechanism gitea-actions and pihole-dns use. It is hand-written for the SAME reason those are:
|
||||||
|
# schemas/servicedefinition.schema.json is `additionalProperties: false` and has NO field for a
|
||||||
|
# type=LoadBalancer Service, for a pinned lbipam VIP, or for raw-TCP north-south exposure at all.
|
||||||
|
# `ingressRoutes` renders HTTPRoutes on the shared Gateway, which is an L7 HTTPS terminator.
|
||||||
|
#
|
||||||
|
# WHY THE L7 GATEWAY IS NOT ENOUGH HERE — and it is NOT because the Gateway fails to serve Plex.
|
||||||
|
# Measured 2026-10-03 through the Gateway VIP: `GET https://plex.home.keithjackson.dev/identity`
|
||||||
|
# returned HTTP 200 with `claimed="1"` and the real machineIdentifier, and the protected endpoints
|
||||||
|
# (/library/sections, /status/sessions, /:/prefs) all returned 401. The L7 path WORKS.
|
||||||
|
#
|
||||||
|
# The problem is DISCOVERY, not proxying. Plex apps (phone, TV, Roku) do not resolve
|
||||||
|
# `plex.${BASE_DOMAIN}`; they ask plex.tv for the server's connection list. A server gets onto that
|
||||||
|
# list one of two ways: it advertises a custom URL (`customConnections`, the ADVERTISE_IP env), or it
|
||||||
|
# publishes a reachable public IP:port for its own tcp/32400. Measured in the live Preferences.xml on
|
||||||
|
# 2026-10-03: `customConnections` ABSENT, `ManualPortMappingMode` ABSENT, while
|
||||||
|
# `PublishServerOnPlexOnlineKey="1"` — the server was trying to publish and had nothing reachable to
|
||||||
|
# publish, so remote clients saw NO connection at all. The operator chose the NATIVE path (this
|
||||||
|
# file) over advertising the Gateway URL: Plex then terminates its own TLS with its
|
||||||
|
# `*.plex.direct` certificate and video streams bypass the Envoy L7 hop entirely.
|
||||||
|
#
|
||||||
|
# VIP: ${PLEX_L4_VIP} (cluster-vars, R-ENV-02 — dev 192.168.1.57, prod 192.168.2.24), PINNED via
|
||||||
|
# lbipam.cilium.io/ips out of the CiliumLoadBalancerIPPool `default-pool`
|
||||||
|
# (${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP}) and ARP-announced on the flat LAN by the existing
|
||||||
|
# CiliumL2AnnouncementPolicy `default-l2` (platform-config/cilium/*). No new Cilium config is needed;
|
||||||
|
# this Service just consumes the pool. PINNING IS NOT OPTIONAL: the address is typed into a WAN
|
||||||
|
# port-forward on the SOHO router, which is hand-maintained and off-repo, so it can never move.
|
||||||
|
# 192.168.2.24 verified FREE live 2026-10-03 — `default-pool` reported IPsTotal 11 / IPsUsed 4 /
|
||||||
|
# IPsAvailable 7, and the only four allocations were .20 (cilium-gateway-shared), .21 (unifi-l4-*),
|
||||||
|
# .22 (crafty-l4-*) and .23 (pihole-dns-*).
|
||||||
|
#
|
||||||
|
# ONE SERVICE, TCP ONLY. Unlike unifi/crafty/pihole-dns this needs no second Service and no
|
||||||
|
# lbipam.cilium.io/sharing-key: Plex's remote access is tcp/32400 and nothing else. (Plex's
|
||||||
|
# DLNA/GDM discovery uses UDP, but that is LAN-only multicast and is not part of remote access.)
|
||||||
|
#
|
||||||
|
# NO `loadBalancerSourceRanges`, DELIBERATELY — and this is the one place this file diverges from
|
||||||
|
# its closest sibling. apps/wave-0/pihole-dns.yaml pins its source range to ${LAN_CIDR} because a
|
||||||
|
# resolver must never answer the internet. This Service is the OPPOSITE case: admitting remote
|
||||||
|
# clients from arbitrary internet addresses is its entire purpose, so there is no range to narrow
|
||||||
|
# to. Plex authenticates every caller itself (measured above: 401 on every protected endpoint
|
||||||
|
# without a token), which is what makes an unfiltered port defensible here.
|
||||||
|
#
|
||||||
|
# NO external-dns ANNOTATION, DELIBERATELY. unifi and crafty both carry
|
||||||
|
# `external-dns.alpha.kubernetes.io/hostname` so external-dns publishes their VIP as a public
|
||||||
|
# Cloudflare record. This Service must not, for pihole-dns's reason: ${PLEX_L4_VIP} is an RFC1918
|
||||||
|
# address that is useless in public DNS. Remote clients get this server's address from plex.tv, not
|
||||||
|
# from the zone. The existing public `plex.${BASE_DOMAIN}` record is published from the rendered
|
||||||
|
# HTTPRoute via the gateway source and is NOT affected by this file — the web UI keeps working
|
||||||
|
# through the Gateway exactly as before.
|
||||||
|
#
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# externalTrafficPolicy: Cluster — inherited from crafty's 8-DAY LAN OUTAGE, not re-derived.
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# `Local` is the tempting choice because it preserves the client source IP, which this service
|
||||||
|
# actually has a use for (see the allowedNetworks note below). It is still WRONG here, for the
|
||||||
|
# reason platform/crafty/service-l4.yaml documents at length: Cilium's L2 announcer elects ONE lease
|
||||||
|
# holder per Service and does NOT follow the backend pod, so with `Local` the VIP can be announced
|
||||||
|
# by a node that has no Plex pod, which black-holes the port while every object still reports
|
||||||
|
# healthy. Plex is a single-replica Deployment with `strategy: Recreate` pinned to wherever the
|
||||||
|
# scheduler put it, so the mismatch is the normal case, not a rare one. Take the SNAT.
|
||||||
|
#
|
||||||
|
# KNOWN CONSEQUENCE OF THAT CHOICE, stated rather than discovered later: `Cluster` SNATs the client
|
||||||
|
# to the ingress NODE's address (192.168.2.1x), which falls inside the `192.168.0.0/16` entry of
|
||||||
|
# catalog/plex.yaml's ALLOWED_NETWORKS. Plex will therefore classify every REMOTE client as LAN.
|
||||||
|
# Measured 2026-10-03 on the Gateway path, which SNATs the same way: Plex logged the request as
|
||||||
|
# `[10.0.2.214:36040 (Allowed Network (WAN))]`. This is a METERING effect, not a security one —
|
||||||
|
# authorization is unchanged (401s above), but Plex's remote-quality/bandwidth limits will not bind
|
||||||
|
# on these streams because it believes they are local. Narrowing ALLOWED_NETWORKS cannot fix it
|
||||||
|
# without also de-classifying genuine LAN clients, so it is accepted.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: plex-l4-tcp
|
||||||
|
namespace: plex
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
annotations:
|
||||||
|
lbipam.cilium.io/ips: "${PLEX_L4_VIP}"
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
externalTrafficPolicy: Cluster
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
ports:
|
||||||
|
- name: plex-remote
|
||||||
|
port: 32400
|
||||||
|
targetPort: 32400
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
|
# INGRESS allow for the raw port (R-NET-01/02).
|
||||||
|
#
|
||||||
|
# The rendered plex.yaml already carries `plex-allow-gateway-ingress` (fromEntities: ingress ->
|
||||||
|
# 32400) and three reciprocal allows for sonarr/radarr/seerr. CiliumNetworkPolicies are ADDITIVE, so
|
||||||
|
# this file adds the off-cluster case without touching those.
|
||||||
|
#
|
||||||
|
# `world` is Cilium's entity for anything outside the cluster, which is what a remote Plex client is.
|
||||||
|
# `host` and `cluster` mirror platform/crafty/networkpolicy.yaml's game-port rule and are what make
|
||||||
|
# this work under `externalTrafficPolicy: Cluster`: after SNAT the packet's source is the ingress
|
||||||
|
# NODE, which Cilium classifies as `host` (same node) or `remote-node` (a different one) — NOT
|
||||||
|
# `world`. Listing `world` alone would compile cleanly and then drop every remote stream, so all
|
||||||
|
# four entities are named.
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: plex-allow-remote-32400
|
||||||
|
namespace: plex
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
ingress:
|
||||||
|
- fromEntities:
|
||||||
|
- world
|
||||||
|
- cluster
|
||||||
|
- host
|
||||||
|
- remote-node
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- { port: "32400", protocol: TCP }
|
||||||
+69
-6
@@ -137,14 +137,37 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: plex-claim
|
name: plex-claim
|
||||||
key: PLEX_CLAIM
|
key: PLEX_CLAIM
|
||||||
|
- name: ADVERTISE_IP
|
||||||
|
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||||
|
- name: PLEX_TLS_CRT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-vip-tls
|
||||||
|
key: tls.crt
|
||||||
|
optional: true
|
||||||
|
- name: PLEX_TLS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-vip-tls
|
||||||
|
key: tls.key
|
||||||
|
optional: true
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
gpu.intel.com/i915: 1
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
- name: media
|
- name: media
|
||||||
mountPath: /media
|
mountPath: /media
|
||||||
readOnly: true
|
readOnly: true
|
||||||
- name: dri
|
lifecycle:
|
||||||
mountPath: /dev/dri
|
postStart:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- "# NOTE \u2014 LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.\n# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a\n# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD\n# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be\n# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` \u2014 bare. Only\n# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be\n# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell\n# variable written with braces would be blanked or would fail the whole Kustomization \u2014\n# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and\n# catalog/render.py now refuses to emit it (which is how this very comment got caught).\n# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the\n# certificate has not issued yet they are genuinely UNSET, not empty \u2014 and under `set -u`\n# the first `[ -n \"$PLEX_TLS_CRT\" ]` would abort the script, which for a postStart hook\n# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists\n# to prevent, so turning it back on here would defeat the whole design. (The alternative,\n# a brace-wrapped `:-` default, would need envsubst escaping \u2014 the trap the note above\n# describes.) Every value below is explicitly guarded instead.\nPX=\"/config/Library/Application Support/Plex Media Server/Preferences.xml\"\nCERTDIR=/config/certs\nP12=$CERTDIR/plex-vip.p12\n# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it\n# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a\n# container-format requirement, not an access control.\nP12PASS=plexvip\nLANNETS='10.0.0.0/8,192.168.0.0/16'\nCERTDOMAIN=plex-vip.${BASE_DOMAIN}\nAPI=http://127.0.0.1:32400\n\n# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so\n# a cert-manager RENEWAL is picked up with no extra moving part.\nif [ -n \"$PLEX_TLS_CRT\" ] && [ -n \"$PLEX_TLS_KEY\" ]; then\n mkdir -p $CERTDIR && chmod 700 $CERTDIR\n printf '%s' \"$PLEX_TLS_CRT\" > $CERTDIR/tls.crt\n printf '%s' \"$PLEX_TLS_KEY\" > $CERTDIR/tls.key\n chmod 600 $CERTDIR/tls.key\n if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \\\n -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then\n mv -f $P12.tmp $P12\n chown 1000:1000 $P12 2>/dev/null || true\n chmod 600 $P12\n echo \"[plex-settings] PKCS#12 bundle written\"\n else\n rm -f $P12.tmp\n echo \"[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle\" >&2\n fi\n rm -f $CERTDIR/tls.key\nelse\n echo \"[plex-settings] WARN: TLS env empty; certificate not installed this start\" >&2\nfi\n\n# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.\ni=0\nwhile [ \"$i\" -lt 45 ]; do\n if [ \"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)\" = \"200\" ]; then\n break\n fi\n i=$(expr $i + 1); sleep 2\ndone\n\n# (3) The prefs API needs the server token. It already lives in Preferences.xml on this\n# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An\n# UNCLAIMED server has none \u2014 a normal state on a fresh /config \u2014 so skip, do not fail.\nTOKEN=$(grep -o 'PlexOnlineToken=\"[^\"]*\"' \"$PX\" 2>/dev/null | head -1 | cut -d'\"' -f2)\nif [ -z \"$TOKEN\" ]; then\n echo \"[plex-settings] not claimed yet \u2014 skipping prefs, will retry next start\" >&2\n exit 0\nfi\n\n# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on\n# a PUT, so a raw '/' or ',' would be read as structure rather than data.\nenc() {\n printf '%s' \"$1\" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \\\n -e 's|:|%3A|g' -e 's| |%20|g'\n}\nsetpref() {\n code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT\
|
||||||
|
\ \\\n -H \"X-Plex-Token: $TOKEN\" \"$API/:/prefs?$1=$(enc \"$2\")\" 2>/dev/null)\n if [ \"$code\" = \"200\" ]; then\n echo \"[plex-settings] set $1\"\n else\n echo \"[plex-settings] WARN: $1 -> HTTP $code\" >&2\n fi\n}\n\n# (4) THE LAN DEFINITION \u2014 the reason this hook was written. Without it Plex has NO notion\n# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests\n# logged \"Allowed Network (WAN)\" and ZERO \"(LAN)\", including Seerr from inside the\n# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks\n# (the ALLOWED_NETWORKS env) only waives AUTH \u2014 it does NOT make a client local. Two\n# different settings; only one of them has an env hook.\nsetpref LanNetworksBandwidth \"$LANNETS\"\n\n# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only\n# if the bundle exists \u2014 pointing Plex at a missing file would break its TLS listener.\nif [ -s \"$P12\" ]; then\n setpref customCertificatePath \"$P12\"\n setpref customCertificateKey \"$P12PASS\"\n setpref customCertificateDomain \"$CERTDOMAIN\"\nelse\n echo \"[plex-settings] WARN: no bundle; leaving certificate prefs untouched\" >&2\nfi\nexit 0\n"
|
||||||
volumes:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
@@ -152,10 +175,6 @@ spec:
|
|||||||
- name: media
|
- name: media
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: plex-media
|
claimName: plex-media
|
||||||
- name: dri
|
|
||||||
hostPath:
|
|
||||||
path: /dev/dri
|
|
||||||
type: DirectoryOrCreate
|
|
||||||
---
|
---
|
||||||
# Service
|
# Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -173,6 +192,28 @@ spec:
|
|||||||
port: 32400
|
port: 32400
|
||||||
targetPort: 32400
|
targetPort: 32400
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-radarr-32400
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: plex-allow-radarr-32400
|
||||||
|
namespace: plex
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
app.kubernetes.io/name: radarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: radarr
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '32400'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400
|
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
@@ -195,6 +236,28 @@ spec:
|
|||||||
- port: '32400'
|
- port: '32400'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-sonarr-32400
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: plex-allow-sonarr-32400
|
||||||
|
namespace: plex
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
k8s:io.kubernetes.pod.namespace: sonarr
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '32400'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress
|
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -319,6 +319,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): qbittorrent-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: qbittorrent-allow-monitoring-scrape
|
||||||
|
namespace: qbittorrent
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: qbittorrent
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: qbittorrent
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
|
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
|
||||||
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
|
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
|
||||||
# qbittorrent (container/Deployment) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
# qbittorrent (container/Deployment), pihole (container/StatefulSet — `availability.replicas: 2`, so
|
||||||
|
# its SLI reads kube_statefulset_status_replicas_ready; see schemas/renderer-contract.md
|
||||||
|
# `### availability`) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
||||||
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
|
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
|
||||||
# tests run against the exact expressions Flux applies, never a copy.
|
# tests run against the exact expressions Flux applies, never a copy.
|
||||||
#
|
#
|
||||||
@@ -41,7 +43,7 @@ tests:
|
|||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
@@ -81,7 +83,7 @@ tests:
|
|||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||||
values: '0+0x20'
|
values: '0+0x20'
|
||||||
@@ -132,7 +134,7 @@ tests:
|
|||||||
values: '0+0x20'
|
values: '0+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||||
values: '0+0x20'
|
values: '0+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||||
values: '0+0x20'
|
values: '0+0x20'
|
||||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||||
values: '1+0x20'
|
values: '1+0x20'
|
||||||
@@ -170,10 +172,10 @@ tests:
|
|||||||
severity: critical
|
severity: critical
|
||||||
service: pihole
|
service: pihole
|
||||||
namespace: pihole
|
namespace: pihole
|
||||||
deployment: pihole
|
statefulset: pihole
|
||||||
exp_annotations:
|
exp_annotations:
|
||||||
summary: "pihole is down"
|
summary: "pihole is down"
|
||||||
description: "pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
description: "pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready)."
|
||||||
- eval_time: 10m
|
- eval_time: 10m
|
||||||
alertname: qbittorrent-down
|
alertname: qbittorrent-down
|
||||||
exp_alerts:
|
exp_alerts:
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ spec:
|
|||||||
- --allowed-group=platform-admins
|
- --allowed-group=platform-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://radarr.${BASE_DOMAIN}/oauth2/callback
|
- --redirect-url=https://radarr.${BASE_DOMAIN}/oauth2/callback
|
||||||
|
- --api-route=^/api/
|
||||||
|
- --api-route=^/signalr/
|
||||||
- --cookie-name=_oauth2_proxy_radarr
|
- --cookie-name=_oauth2_proxy_radarr
|
||||||
- --cookie-domain=radarr.${BASE_DOMAIN}
|
- --cookie-domain=radarr.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.${BASE_DOMAIN}
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
@@ -281,6 +283,28 @@ spec:
|
|||||||
- port: '9117'
|
- port: '9117'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy egress allow (R-NET-02): radarr-allow-plex-32400
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: radarr-allow-plex-32400
|
||||||
|
namespace: radarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: radarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: radarr
|
||||||
|
egress:
|
||||||
|
- toEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
k8s:io.kubernetes.pod.namespace: plex
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '32400'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): radarr-allow-seerr-7878
|
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): radarr-allow-seerr-7878
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
@@ -323,6 +347,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): radarr-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: radarr-allow-monitoring-scrape
|
||||||
|
namespace: radarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: radarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: radarr
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -281,6 +281,28 @@ spec:
|
|||||||
- port: '9117'
|
- port: '9117'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy egress allow (R-NET-02): sonarr-allow-plex-32400
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: sonarr-allow-plex-32400
|
||||||
|
namespace: sonarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
egress:
|
||||||
|
- toEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
k8s:io.kubernetes.pod.namespace: plex
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '32400'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): sonarr-allow-seerr-8989
|
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): sonarr-allow-seerr-8989
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
@@ -323,6 +345,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: sonarr-allow-monitoring-scrape
|
||||||
|
namespace: sonarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ auth:
|
|||||||
# This grants no real privilege: platform-admins already maps to cluster-admin via
|
# This grants no real privilege: platform-admins already maps to cluster-admin via
|
||||||
# k8s-oidc-rbac.yaml (oidc:platform-admins), so a member can already exec into these pods.
|
# k8s-oidc-rbac.yaml (oidc:platform-admins), so a member can already exec into these pods.
|
||||||
- platform-admins # operators of these services must not be locked out of them
|
- platform-admins # operators of these services must not be locked out of them
|
||||||
|
# Answer Jackett's OWN API with 401 instead of a 302 to Keycloak (schema 1.9.0; renders
|
||||||
|
# --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
|
||||||
|
#
|
||||||
|
# MEASURED on this pod's access log, 2026-09-30: on /api/ paths, 33 x 302 against 2 x 401 —
|
||||||
|
# and 4 of 6 POSTs were answered BY THE PROXY and never reached Jackett (access-log upstream
|
||||||
|
# field `-`, not `/`), including both `/api/v2.0/server/update` calls. Jackett's jQuery does
|
||||||
|
# not send the exact `Accept: application/json` oauth2-proxy's AJAX guard requires, so the
|
||||||
|
# 302 was followed invisibly, satisfied silently by the live Keycloak SSO session, and
|
||||||
|
# resolved 200 carrying the dashboard's HTML where the JavaScript expected JSON. A browser
|
||||||
|
# following a 302 also re-issues a POST as a GET and DROPS THE BODY, which is why "adding an
|
||||||
|
# indexer silently does nothing" was the user-visible symptom and nothing logged a failure.
|
||||||
|
#
|
||||||
|
# SCOPE: `/api/` only. Jackett serves its UI at /UI/ and its static assets elsewhere, and a
|
||||||
|
# browser never NAVIGATES to /api/ — so a plain page load is still 302'd into a silent SSO
|
||||||
|
# sign-in, which is the behaviour that must not change.
|
||||||
|
apiRoutes:
|
||||||
|
- ^/api/
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=0.22.0" # stay on the Jackett 0.22.x stable line
|
range: ">=0.22.0" # stay on the Jackett 0.22.x stable line
|
||||||
|
|||||||
+204
-1
@@ -12,6 +12,42 @@ storage:
|
|||||||
- name: config
|
- name: config
|
||||||
size: 2Gi
|
size: 2Gi
|
||||||
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
||||||
|
# THE PVC WAS MOUNTED SOMEWHERE PIHOLE NEVER WRITES, AND SO HELD NOTHING AT ALL. Without an
|
||||||
|
# explicit mountPath the renderer defaults to /data/<name>, i.e. /data/config — but PiHole keeps
|
||||||
|
# every piece of its state in /etc/pihole. MEASURED on live prod 2026-10-04: `/etc/pihole` (7.1 M
|
||||||
|
# — gravity.db, pihole.toml, the query DB) was on the **overlay** filesystem, i.e. the
|
||||||
|
# container's EPHEMERAL layer, while `/dev/longhorn/pvc-849edb86...` sat mounted at
|
||||||
|
# /data/config holding `lost+found` and 24 K of nothing. Three consequences, all live until now:
|
||||||
|
# * every restart silently discarded PiHole's whole state, re-downloading 72 k gravity domains
|
||||||
|
# and regenerating the admin password hash;
|
||||||
|
# * `backup.policy: daily-offcluster` below has been faithfully backing up an EMPTY volume,
|
||||||
|
# which is worse than no backup because it reports success;
|
||||||
|
# * it is also why phase 1's password/adlist declaration mattered more than it first appeared —
|
||||||
|
# that state was not merely undeclared, it was ephemeral.
|
||||||
|
# This is the same class of bug catalog/plex.yaml's `config` entry documents ("Without this the
|
||||||
|
# PVC sat unused and Plex wrote to the container's EPHEMERAL layer").
|
||||||
|
#
|
||||||
|
# /etc/pihole IS THE IMAGE'S INTENDED MOUNT POINT, and an EMPTY volume there self-initialises.
|
||||||
|
# Verified in the running image, not assumed:
|
||||||
|
# * `/usr/bin/start.sh` (PID 1) calls `migrate_gravity`, whose `migrate_to_database()` creates
|
||||||
|
# the gravity database when `[ ! -e "$gravityDBfile" ]` — the same branch the postStart hook
|
||||||
|
# below relies on, and the branch that produced the current ephemeral copy.
|
||||||
|
# * FTL writes `pihole.toml` itself from the `FTLCONF_*` env above (that is what the
|
||||||
|
# `### CHANGED (env)` markers in it are).
|
||||||
|
# * NO `podSecurityContext.fsGroup` is needed and adding one would be inert: `start.sh` line 5
|
||||||
|
# is `chown -R pihole:pihole /etc/pihole/ /var/log/pihole/` and PID 1 runs as **uid 0**, so
|
||||||
|
# the image fixes ownership of a fresh root-owned volume before anything else starts. Same
|
||||||
|
# arrangement as plex's image managing its own /config ownership.
|
||||||
|
# * the image's own default for `files.macvendor` was moved OUT of this directory (to
|
||||||
|
# `/macvendor.db`), which is what a maintainer does to a path they expect to be a volume.
|
||||||
|
#
|
||||||
|
# WHY IT IS FIXED IN THIS BRANCH rather than left for its own: `availability.replicas: 2` renders
|
||||||
|
# per-replica volumeClaimTemplates, and with the wrong mountPath those would be two 2 Gi Longhorn
|
||||||
|
# volumes (times three Longhorn replicas each) holding nothing but `lost+found` while all state
|
||||||
|
# stayed ephemeral — paying the cost of persistence and getting none of it. It also REMOVES this
|
||||||
|
# change's main operational risk: with gravity.db persisted, a pod restart is cheap, instead of
|
||||||
|
# every restart re-downloading 72 k domains inside a wave that has a 5 m timeout.
|
||||||
|
mountPath: /etc/pihole
|
||||||
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
||||||
# ------------------------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------------------------
|
||||||
# LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to.
|
# LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to.
|
||||||
@@ -98,8 +134,64 @@ env:
|
|||||||
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
|
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
|
||||||
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
|
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
|
||||||
# while resolution for the zone does not work. Confirmed both ways against the live image.
|
# while resolution for the zone does not work. Confirmed both ways against the live image.
|
||||||
|
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
|
||||||
|
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
|
||||||
|
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
|
||||||
|
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
|
||||||
|
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
|
||||||
|
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
|
||||||
|
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
|
||||||
|
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
|
||||||
|
# every client that takes the ADVERTISED connection cannot reach the server at all.
|
||||||
- name: FTLCONF_misc_dnsmasq_lines
|
- name: FTLCONF_misc_dnsmasq_lines
|
||||||
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}"
|
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
|
||||||
|
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
|
||||||
|
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
|
||||||
|
# carries the full rationale and the measurement that found it.
|
||||||
|
#
|
||||||
|
# `webserver.api.password` takes CLEARTEXT and FTL hashes it itself into `webserver.api.pwhash`.
|
||||||
|
# Verified in the running image, not assumed: `pihole setpassword` is itself
|
||||||
|
# `setFTLConfigValue "webserver.api.password" "$PASSWORD"`, commented upstream as "pihole-FTL will
|
||||||
|
# automatically hash the password". So the BALLOON-SHA256 hash on the PVC is now a DERIVED artefact
|
||||||
|
# and this is its source of truth. Rotating the Secret rotates the password.
|
||||||
|
- name: FTLCONF_webserver_api_password
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: pihole-bootstrap
|
||||||
|
key: webui-password
|
||||||
|
availability:
|
||||||
|
# TWO REPLICAS, ON DIFFERENT NODES. LAN DNS is a critical network service and it was ONE pod on
|
||||||
|
# ONE node. Measured 2026-10-04, that cost more than "until it reschedules":
|
||||||
|
# * the node running the pod dies -> ~40s to mark it NotReady, then `tolerationSeconds: 300`
|
||||||
|
# before the pod is even evicted, then reschedule, then Longhorn must force-detach the RWO
|
||||||
|
# volume from a dead node and reattach it. Roughly 6-10 MINUTES with no LAN resolution.
|
||||||
|
# * the node holding the `pihole-dns-*` L2 lease dies -> Cilium re-elects an announcer, tens of
|
||||||
|
# seconds. (These were DIFFERENT nodes: lease on k3s-prod-2, pod on k3s-prod-1. Two of three
|
||||||
|
# nodes were single points of failure for LAN DNS, with wildly different blast radii.)
|
||||||
|
# The Services already tolerate this: `externalTrafficPolicy: Cluster` on the DNS LoadBalancers
|
||||||
|
# (apps/wave-0/pihole-dns.yaml) means the L2 lease holder forwards to ANY ready backend, so a
|
||||||
|
# second replica is picked up with no change there.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES NOT FIX, and it is the bigger risk: LAN DNS still depends on the CLUSTER. etcd
|
||||||
|
# quorum loss, a Cilium regression, a Longhorn problem or a bad reconcile takes DNS down
|
||||||
|
# everywhere regardless of replica count. The only thing that removes that dependency is a
|
||||||
|
# resolver outside the cluster carrying the same `address=` lines. Considered and not chosen
|
||||||
|
# (operator decision, 2026-10-04); see decisions/2026-10-04-pihole-multi-replica.md.
|
||||||
|
#
|
||||||
|
# THE PREREQUISITE WAS DONE FIRST, and this field would have been actively harmful without it. A
|
||||||
|
# replica gets a FRESH volume, and until 2026-10-04 PiHole's admin password and adlists lived only
|
||||||
|
# on the PVC — so replica 1 would have come up SERVING DNS AND BLOCKING NOTHING, with a different
|
||||||
|
# password, and answers would have varied by which pod replied. Both are now declared (the
|
||||||
|
# password as a SOPS Secret -> FTLCONF_webserver_api_password; the adlists reconciled into
|
||||||
|
# gravity.db by the postStart hook below), which is what makes a fresh replica identical to its
|
||||||
|
# sibling. See decisions/2026-10-04-pihole-declare-state.md.
|
||||||
|
#
|
||||||
|
# WHY 2 AND NOT 3. Two tolerates the loss of any single node, which is the stated goal, and leaves
|
||||||
|
# a spare node for the evicted replica to land on under the HARD anti-affinity the renderer emits.
|
||||||
|
# Three would pin one replica per node with no spare, so a single node failure would leave a
|
||||||
|
# replica permanently Pending and — `apps-wave-0` being `wait: true` — stall the app tier for as
|
||||||
|
# long as the node was down. Two is the count that degrades gracefully here.
|
||||||
|
replicas: 2
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: pihole.${BASE_DOMAIN}
|
- host: pihole.${BASE_DOMAIN}
|
||||||
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
||||||
@@ -107,6 +199,117 @@ auth:
|
|||||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
||||||
groups:
|
groups:
|
||||||
- platform-admins # sensitive admin UI: authenticated AND in group (R-IDP-06)
|
- platform-admins # sensitive admin UI: authenticated AND in group (R-IDP-06)
|
||||||
|
# NO `apiRoutes` HERE, DELIBERATELY. PiHole's admin UI serves no /api/ path, so the `^/api/`
|
||||||
|
# that jackett and radarr need would match nothing on a good day — and a BROADER guess would
|
||||||
|
# answer PiHole's own page loads with 401, leaving a logged-out operator on a bare error page
|
||||||
|
# with no sign-in offered. This app is the reason auth.apiRoutes is per-service (schema 1.9.0)
|
||||||
|
# rather than a renderer constant: the measurement that justifies the flag on two apps is
|
||||||
|
# evidence about those two apps. Its oauth2-proxy log holds 0 unauthenticated requests across
|
||||||
|
# the current pod's whole 23h lifetime (measured 2026-10-01), so there is nothing to act on.
|
||||||
|
lifecycle:
|
||||||
|
# LEVEL-TRIGGERED ADLIST ASSERTION, run on EVERY container start. Same pattern, and the same
|
||||||
|
# reasoning, as catalog/qbittorrent.yaml's postStart: desired state lives in git and is
|
||||||
|
# RE-ASSERTED unconditionally rather than set once by hand.
|
||||||
|
#
|
||||||
|
# WHY THIS IS NEEDED AT ALL — the blocklists were NOT reproducible from git. Measured on live prod
|
||||||
|
# 2026-10-04: `gravity.db` held exactly ONE adlist (StevenBlack's hosts, enabled) and 72,234
|
||||||
|
# gravity domains, and NOTHING in this repo named it. Worse, a FRESH database does not seed it:
|
||||||
|
# the image's own schema template `/etc/.pihole/advanced/Templates/gravity.db.sql` creates the
|
||||||
|
# `adlist` table and inserts NO rows into it (grepped in the running image — there is no
|
||||||
|
# `INSERT INTO adlist` at all). So a rebuild from git, or a replica with a new PVC, would come up
|
||||||
|
# SERVING DNS AND BLOCKING NOTHING, silently and with every object reporting healthy.
|
||||||
|
#
|
||||||
|
# WHY A postStart HOOK AND NOT A FILE. PiHole v6 keeps adlists as DB rows, not config. The
|
||||||
|
# `/etc/pihole/adlists.list` file still exists in `gravity.sh` but is MIGRATION-ONLY: its import
|
||||||
|
# sits inside `migrate_to_database()`, guarded by `if [ ! -e "$gravityDBfile" ]`, so it fires only
|
||||||
|
# while CREATING a database and never again on an existing one. A ConfigMap mounted there would
|
||||||
|
# therefore be silently ignored on every pod this cluster currently runs. There is also no
|
||||||
|
# `pihole adlist` CLI subcommand (only allow/deny for domains), so SQL against the DB is the
|
||||||
|
# actual interface. FTL ships an embedded shell (`pihole-FTL sqlite3`, SQLite 3.53.1) so no extra
|
||||||
|
# tooling is needed in the image.
|
||||||
|
#
|
||||||
|
# IT RECONCILES, IT DOES NOT JUST ADD: adlists present in the DB but absent from DECLARED below are
|
||||||
|
# DISABLED (not deleted — deleting cascades to `gravity` rows and would force a full re-download of
|
||||||
|
# everything on a typo). So this list is the source of truth in both directions, and `pihole -g`
|
||||||
|
# runs ONLY when something actually changed, which keeps the steady-state start free of a 70k-domain
|
||||||
|
# rebuild.
|
||||||
|
#
|
||||||
|
# NO BRACED DOLLAR EXPANSION ANYWHERE IN THIS SCRIPT, DELIBERATELY — the same contract
|
||||||
|
# catalog/qbittorrent.yaml documents at length. apps-wave-0 declares postBuild.substituteFrom
|
||||||
|
# cluster-vars, so Flux runs envsubst over this whole manifest at reconcile time; a `${VAR}` form
|
||||||
|
# would behave differently on the cluster than under a plain `kubectl apply` of the golden. Bare
|
||||||
|
# `$VAR` and `$(cmd)` are untouched by envsubst and so behave identically however this is run.
|
||||||
|
# `render.py`'s assert_substitution_safe refuses to emit a manifest that breaks this.
|
||||||
|
postStart:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and
|
||||||
|
# does NOT run a gravity rebuild.
|
||||||
|
set -u
|
||||||
|
GRAVITY=/etc/pihole/gravity.db
|
||||||
|
|
||||||
|
# THE DECLARED SET — one URL per line, the source of truth for what PiHole blocks.
|
||||||
|
# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.
|
||||||
|
DECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
|
||||||
|
|
||||||
|
# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,
|
||||||
|
# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.
|
||||||
|
i=0
|
||||||
|
while [ ! -s "$GRAVITY" ] && [ "$i" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done
|
||||||
|
if [ ! -s "$GRAVITY" ]; then
|
||||||
|
echo "pihole-adlists: gravity.db absent after 180s - leaving adlists untouched" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sql() { pihole-FTL sqlite3 "$GRAVITY" "$1"; }
|
||||||
|
CHANGED=0
|
||||||
|
|
||||||
|
# 1) Every declared URL must be present AND enabled.
|
||||||
|
echo "$DECLARED" | while IFS= read -r url; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
printf '%s\n' "$url"
|
||||||
|
done > /tmp/declared.txt
|
||||||
|
while IFS= read -r url; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
# Single quotes inside a URL would break this SQL; adlist URLs do not contain them.
|
||||||
|
have=$(sql "select count(*) from adlist where address='$url';")
|
||||||
|
if [ "$have" = "0" ]; then
|
||||||
|
echo "pihole-adlists: adding $url"
|
||||||
|
sql "insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');"
|
||||||
|
CHANGED=1
|
||||||
|
else
|
||||||
|
en=$(sql "select enabled from adlist where address='$url';")
|
||||||
|
if [ "$en" != "1" ]; then
|
||||||
|
echo "pihole-adlists: re-enabling $url"
|
||||||
|
sql "update adlist set enabled=1 where address='$url';"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < /tmp/declared.txt
|
||||||
|
|
||||||
|
# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).
|
||||||
|
sql "select address from adlist where enabled=1;" | while IFS= read -r addr; do
|
||||||
|
[ -n "$addr" ] || continue
|
||||||
|
if ! grep -qxF "$addr" /tmp/declared.txt; then
|
||||||
|
echo "pihole-adlists: disabling undeclared $addr"
|
||||||
|
sql "update adlist set enabled=0 where address='$addr';"
|
||||||
|
echo changed >> /tmp/adlist-changed
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -s /tmp/adlist-changed ] && CHANGED=1
|
||||||
|
rm -f /tmp/adlist-changed /tmp/declared.txt
|
||||||
|
|
||||||
|
# 3) Rebuild gravity ONLY if the declared set actually moved.
|
||||||
|
if [ "$CHANGED" = "1" ]; then
|
||||||
|
echo "pihole-adlists: declared set changed - running gravity update"
|
||||||
|
pihole -g || echo "pihole-adlists: gravity update failed; existing gravity.db retained" >&2
|
||||||
|
else
|
||||||
|
echo "pihole-adlists: already matches the declared set - nothing to do"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=2024.0.0"
|
range: ">=2024.0.0"
|
||||||
|
|||||||
+256
-41
@@ -72,55 +72,271 @@ env:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: plex-claim
|
name: plex-claim
|
||||||
key: PLEX_CLAIM
|
key: PLEX_CLAIM
|
||||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
# ADVERTISE_IP ("Custom server access URLs") — the connection Plex publishes to plex.tv.
|
||||||
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
|
#
|
||||||
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
|
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
|
||||||
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
|
# property is what this field keeps getting wrong, so it is the first thing to check before
|
||||||
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
|
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
|
||||||
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
|
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
|
||||||
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
|
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
|
||||||
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
|
# clients hard-code a public resolver.
|
||||||
|
#
|
||||||
|
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
|
||||||
|
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
|
||||||
|
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
|
||||||
|
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
|
||||||
|
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
|
||||||
|
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
|
||||||
|
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
|
||||||
|
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
|
||||||
|
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
|
||||||
|
# the Plex path entirely and were talking to the router.
|
||||||
|
#
|
||||||
|
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
|
||||||
|
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
|
||||||
|
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
|
||||||
|
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
|
||||||
|
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
|
||||||
|
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
|
||||||
|
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
|
||||||
|
#
|
||||||
|
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
|
||||||
|
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
|
||||||
|
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
|
||||||
|
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
|
||||||
|
# configuration of that name that is right for both. `plex.direct` is right for both.
|
||||||
|
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
|
||||||
|
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
|
||||||
|
#
|
||||||
|
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
|
||||||
|
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
|
||||||
|
# listener with
|
||||||
|
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
|
||||||
|
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
|
||||||
|
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
|
||||||
|
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
|
||||||
|
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
|
||||||
|
# least fails loudly and only after someone changes the VIP.
|
||||||
|
#
|
||||||
|
# The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections"
|
||||||
|
# "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the
|
||||||
|
# PLEX_CLAIM/PlexOnlineToken guard, with its `/.firstRunComplete` marker on the container OVERLAY
|
||||||
|
# fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not a web
|
||||||
|
# form, owns the value.
|
||||||
|
- name: ADVERTISE_IP
|
||||||
|
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||||
|
# THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume.
|
||||||
|
# `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly
|
||||||
|
# `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this
|
||||||
|
# workload). The postStart hook converts it to the PKCS#12 bundle Plex requires.
|
||||||
|
#
|
||||||
|
# WHY ENV AND NOT A VOLUME, which would be the conventional choice. MEASURED 2026-10-04:
|
||||||
|
# `schemas/servicedefinition.schema.json` `storage` accepts only
|
||||||
|
# [class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`,
|
||||||
|
# `catalog/render.py` emits NO secret volumes, and no rendered app mounts a Secret as a volume.
|
||||||
|
# Mounting one therefore needs a schema + renderer change; `env.valueFrom.secretKeyRef` is
|
||||||
|
# already in the schema and already used by PLEX_CLAIM above. PEM is text, so it survives an env
|
||||||
|
# var intact (a raw PKCS#12 would not — it is binary and carries NUL bytes).
|
||||||
|
#
|
||||||
|
# COST, STATED PLAINLY: the private key is readable from this container's environment
|
||||||
|
# (/proc/1/environ, `kubectl exec env`) by anything that can already exec into the pod. That is
|
||||||
|
# the same audience that can read the converted bundle under /config, so the marginal exposure is
|
||||||
|
# small — but a secret VOLUME would still be the better shape, and adding secret-volume support
|
||||||
|
# to the schema is the clean follow-up that removes this note.
|
||||||
|
#
|
||||||
|
# `optional: true` IS LOAD-BEARING, NOT DEFENSIVE CLUTTER (schema 1.11.0, added for this). The
|
||||||
|
# Certificate is issued INTO THIS NAMESPACE, and the namespace is created by the rendered
|
||||||
|
# apps/wave-0/plex.yaml itself — so the cert CANNOT be issued in an earlier tier, and the
|
||||||
|
# Certificate and this Deployment land in the SAME reconcile. On a cold bootstrap the Secret is
|
||||||
|
# therefore briefly absent while cert-manager completes a DNS01 challenge. With a HARD ref, Plex
|
||||||
|
# would sit in CreateContainerConfigError until a certificate issued — and because `apps-wave-0`
|
||||||
|
# is `wait: true` with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE
|
||||||
|
# APP TIER (the same hazard the `hardware` block below documents for the GPU resource). Optional
|
||||||
|
# instead means: no cert -> Plex still starts and serves, the postStart hook logs that the
|
||||||
|
# certificate was not installed, and the next start after issuance converges. Plex falls back to
|
||||||
|
# its own `*.plex.direct` certificate in the meantime, so the degraded state is "advertised URL
|
||||||
|
# not yet servable over TLS", not "media server down".
|
||||||
|
- name: PLEX_TLS_CRT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-vip-tls
|
||||||
|
key: tls.crt
|
||||||
|
optional: true
|
||||||
|
- name: PLEX_TLS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-vip-tls
|
||||||
|
key: tls.key
|
||||||
|
optional: true
|
||||||
|
lifecycle:
|
||||||
|
# LEVEL-TRIGGERED PLEX SETTINGS, re-asserted on EVERY container start — the same pattern and the
|
||||||
|
# same reasoning as catalog/pihole.yaml's and catalog/qbittorrent.yaml's postStart: desired state
|
||||||
|
# lives in git and is re-asserted unconditionally rather than typed into a web form once. Typing
|
||||||
|
# settings into the web form is precisely what took this service down on 2026-10-04.
|
||||||
|
#
|
||||||
|
# IT EXISTS BECAUSE THE IMAGE HAS NO ENV FOR THESE SETTINGS. Measured 2026-10-04 across the whole
|
||||||
|
# init surface (/etc/cont-init.d, /etc/services.d, /plex-common.sh, /usr/local/bin): the image
|
||||||
|
# calls setPref for exactly SIX prefs — MachineIdentifier, ProcessedMachineIdentifier,
|
||||||
|
# PlexOnlineToken, customConnections, allowedNetworks, TranscoderTempDirectory — and supports NO
|
||||||
|
# generic PLEX_PREFERENCE* hook of any kind. The two things we need (a LAN definition and a custom
|
||||||
|
# certificate) are reachable ONLY through Plex's own API, so this hook is the only GitOps path.
|
||||||
|
#
|
||||||
|
# IT MUST NEVER FAIL. A postStart hook that exits non-zero makes kubelet KILL the container, so
|
||||||
|
# every step is bounded and the script ends with an unconditional `exit 0`. A missing token, an
|
||||||
|
# unissued certificate or an unreachable API degrades to "not applied this start" and is retried
|
||||||
|
# on the next one; it never crashloops Plex.
|
||||||
|
postStart:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
# NOTE — LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.
|
||||||
|
# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a
|
||||||
|
# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD
|
||||||
|
# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be
|
||||||
|
# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` — bare. Only
|
||||||
|
# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be
|
||||||
|
# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell
|
||||||
|
# variable written with braces would be blanked or would fail the whole Kustomization —
|
||||||
|
# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and
|
||||||
|
# catalog/render.py now refuses to emit it (which is how this very comment got caught).
|
||||||
|
# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the
|
||||||
|
# certificate has not issued yet they are genuinely UNSET, not empty — and under `set -u`
|
||||||
|
# the first `[ -n "$PLEX_TLS_CRT" ]` would abort the script, which for a postStart hook
|
||||||
|
# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists
|
||||||
|
# to prevent, so turning it back on here would defeat the whole design. (The alternative,
|
||||||
|
# a brace-wrapped `:-` default, would need envsubst escaping — the trap the note above
|
||||||
|
# describes.) Every value below is explicitly guarded instead.
|
||||||
|
PX="/config/Library/Application Support/Plex Media Server/Preferences.xml"
|
||||||
|
CERTDIR=/config/certs
|
||||||
|
P12=$CERTDIR/plex-vip.p12
|
||||||
|
# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it
|
||||||
|
# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a
|
||||||
|
# container-format requirement, not an access control.
|
||||||
|
P12PASS=plexvip
|
||||||
|
LANNETS='10.0.0.0/8,192.168.0.0/16'
|
||||||
|
CERTDOMAIN=plex-vip.${BASE_DOMAIN}
|
||||||
|
API=http://127.0.0.1:32400
|
||||||
|
|
||||||
|
# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so
|
||||||
|
# a cert-manager RENEWAL is picked up with no extra moving part.
|
||||||
|
if [ -n "$PLEX_TLS_CRT" ] && [ -n "$PLEX_TLS_KEY" ]; then
|
||||||
|
mkdir -p $CERTDIR && chmod 700 $CERTDIR
|
||||||
|
printf '%s' "$PLEX_TLS_CRT" > $CERTDIR/tls.crt
|
||||||
|
printf '%s' "$PLEX_TLS_KEY" > $CERTDIR/tls.key
|
||||||
|
chmod 600 $CERTDIR/tls.key
|
||||||
|
if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \
|
||||||
|
-in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then
|
||||||
|
mv -f $P12.tmp $P12
|
||||||
|
chown 1000:1000 $P12 2>/dev/null || true
|
||||||
|
chmod 600 $P12
|
||||||
|
echo "[plex-settings] PKCS#12 bundle written"
|
||||||
|
else
|
||||||
|
rm -f $P12.tmp
|
||||||
|
echo "[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle" >&2
|
||||||
|
fi
|
||||||
|
rm -f $CERTDIR/tls.key
|
||||||
|
else
|
||||||
|
echo "[plex-settings] WARN: TLS env empty; certificate not installed this start" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 45 ]; do
|
||||||
|
if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)" = "200" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$(expr $i + 1); sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# (3) The prefs API needs the server token. It already lives in Preferences.xml on this
|
||||||
|
# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An
|
||||||
|
# UNCLAIMED server has none — a normal state on a fresh /config — so skip, do not fail.
|
||||||
|
TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$PX" 2>/dev/null | head -1 | cut -d'"' -f2)
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "[plex-settings] not claimed yet — skipping prefs, will retry next start" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on
|
||||||
|
# a PUT, so a raw '/' or ',' would be read as structure rather than data.
|
||||||
|
enc() {
|
||||||
|
printf '%s' "$1" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \
|
||||||
|
-e 's|:|%3A|g' -e 's| |%20|g'
|
||||||
|
}
|
||||||
|
setpref() {
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT \
|
||||||
|
-H "X-Plex-Token: $TOKEN" "$API/:/prefs?$1=$(enc "$2")" 2>/dev/null)
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo "[plex-settings] set $1"
|
||||||
|
else
|
||||||
|
echo "[plex-settings] WARN: $1 -> HTTP $code" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# (4) THE LAN DEFINITION — the reason this hook was written. Without it Plex has NO notion
|
||||||
|
# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests
|
||||||
|
# logged "Allowed Network (WAN)" and ZERO "(LAN)", including Seerr from inside the
|
||||||
|
# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks
|
||||||
|
# (the ALLOWED_NETWORKS env) only waives AUTH — it does NOT make a client local. Two
|
||||||
|
# different settings; only one of them has an env hook.
|
||||||
|
setpref LanNetworksBandwidth "$LANNETS"
|
||||||
|
|
||||||
|
# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only
|
||||||
|
# if the bundle exists — pointing Plex at a missing file would break its TLS listener.
|
||||||
|
if [ -s "$P12" ]; then
|
||||||
|
setpref customCertificatePath "$P12"
|
||||||
|
setpref customCertificateKey "$P12PASS"
|
||||||
|
setpref customCertificateDomain "$CERTDOMAIN"
|
||||||
|
else
|
||||||
|
echo "[plex-settings] WARN: no bundle; leaving certificate prefs untouched" >&2
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
|
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
|
||||||
# INTENT ONLY. This says "this workload needs an Intel Quick Sync render device"; it does NOT name a
|
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
|
||||||
# device path. The renderer currently satisfies it with exactly two things — a `/dev/dri` hostPath
|
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||||
# volume (`type: DirectoryOrCreate`) and a read-write `/dev/dri` volumeMount on the app container —
|
# How the platform hands the device over is `catalog/render.py`'s business and is documented in
|
||||||
# and nothing else: no nodeSelector, no privileged, no resources (schemas/renderer-contract.md
|
# `schemas/renderer-contract.md` `### hardware` — read that for the mechanism, never this comment.
|
||||||
# `### hardware`). If that mechanism is later replaced by the Intel GPU device plugin
|
# The payoff is proven, not theoretical: on 2026-10-01 the mechanism was swapped outright (hostPath
|
||||||
# (`gpu.intel.com/i915`), it is a `catalog/render.py` change and THIS BLOCK DOES NOT MOVE. Do not
|
# -> Intel GPU device plugin extended resource) and THIS BLOCK DID NOT MOVE — a `render.py` change
|
||||||
# "simplify" it into a raw `/dev/...` path; that is the whole point of the enum.
|
# plus a re-render, zero catalog edits. Do not "simplify" it into a raw `/dev/...` path; that is the
|
||||||
|
# whole point of the enum.
|
||||||
#
|
#
|
||||||
# THE DEVICE IS PRESENT ON EVERY PROD NODE, so nothing had to be installed and Plex can land
|
# THE DEVICE IS PRESENT ON EVERY PROD NODE, so nothing had to be installed and Plex can land
|
||||||
# anywhere. Measured on all three nodes 2026-09-30: `/dev/dri/renderD128` present, `root:render`,
|
# anywhere. Measured on all three nodes 2026-09-30: `/dev/dri/renderD128` present, `root:render`,
|
||||||
# GID 992, mode 0660, driver `i915` bound (NOT `xe`), UHD Graphics 630 on an i7-9700T, media engines
|
# GID 992, mode 0660, driver `i915` bound (NOT `xe`), UHD Graphics 630 on an i7-9700T, media engines
|
||||||
# `vcs0`/`vecs0` live, iGPU enabled in BIOS on each. Plex happens to run on k3s-prod-2 today, but
|
# `vcs0`/`vecs0` live, iGPU enabled in BIOS on each. Measured again 2026-10-01, one layer up: all
|
||||||
# that is scheduler history rather than a requirement — this field deliberately renders no
|
# three nodes advertise `gpu.intel.com/i915: 1` in `.status.allocatable`. This field renders no
|
||||||
# nodeSelector, because placement is a separate concern with its own justification.
|
# nodeSelector; placement is a separate concern with its own justification.
|
||||||
|
#
|
||||||
|
# PLEX'S SCHEDULABILITY IS NOW COUPLED TO THE `intel-gpu-plugin` DaemonSet, and that is a real cost,
|
||||||
|
# not a footnote. The current mechanism is a hard scheduling constraint: if the resource is
|
||||||
|
# unavailable (plugin broken, removed, or not yet reconciled) this pod is UNSCHEDULABLE, and because
|
||||||
|
# `apps-wave-0` is `wait: true` with `apps-wave-1` `dependsOn` it, that STALLS THE WHOLE APP TIER.
|
||||||
|
# The previous hostPath mechanism degraded silently to software transcoding instead — but it never
|
||||||
|
# actually granted access (device-cgroup EPERM, measured 2026-10-01), so there was nothing to keep.
|
||||||
|
# THE ESCAPE HATCH IS THIS BLOCK: delete these two lines and re-render, and Plex goes back to
|
||||||
|
# software transcoding and the tier unblocks. One field, no code change. `runbooks/plex-quicksync.md`
|
||||||
|
# carries that procedure for whoever needs it at 2am.
|
||||||
#
|
#
|
||||||
# NO `podSecurityContext.supplementalGroups`, AND ADDING ONE WOULD BE INERT — do not "fix" this.
|
# NO `podSecurityContext.supplementalGroups`, AND ADDING ONE WOULD BE INERT — do not "fix" this.
|
||||||
# The device is `root:render` 0660 and this process is uid/gid 1000, so the obvious move is to push
|
# The device is `root:render` 0660 and this process is uid/gid 1000, so the obvious move is to push
|
||||||
# the host `render` GID in as a supplemental group. It cannot work here. MEASURED IN THE RUNNING
|
# the host `render` GID in as a supplemental group. It cannot work here, and this survived the
|
||||||
# CONTAINER, 2026-09-30: the image's own
|
# mechanism swap unchanged. MEASURED IN THE RUNNING CONTAINER, 2026-09-30 and again 2026-10-01: the
|
||||||
# `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` stats every char device under
|
# image's own `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` stats every char device
|
||||||
# `/dev/dri`, `groupadd`s a matching group INSIDE the container when none exists, and
|
# under `/dev/dri`, `groupadd`s a matching group INSIDE the container when none exists, and
|
||||||
# `usermod -a -G`s the `plex` user into it — every start, nothing hardcoded, which also makes a host
|
# `usermod -a -G`s the `plex` user into it — every start, nothing hardcoded, which also makes a host
|
||||||
# GID renumbering self-healing (no GID is pinned anywhere in this repo). The service then execs via
|
# GID renumbering self-healing (no GID is pinned anywhere in this repo) and which works the same way
|
||||||
# `s6-setuidgid plex`, and `s6-setuidgid` REPLACES the process group list from `/etc/group` rather
|
# on a PLUGIN-INJECTED device as on a bind-mounted one. Measured live, `Plex Media Server` ran with
|
||||||
# than appending to it: `Plex Media Server` runs with `Groups: 100 1000`, and root's gid `0` — which
|
# `Groups: 44 100 992 1000` — 992 IS the host `render` GID, i.e. the hook had already done the job.
|
||||||
# PID 1 (`s6-svscan`) does hold — is GONE. Anything Kubernetes supplies therefore reaches s6-svscan
|
# The service execs via `s6-setuidgid plex`, and `s6-setuidgid` REPLACES the process group list from
|
||||||
# and is discarded before Plex ever starts. Mounting the device is sufficient; the group is the
|
# `/etc/group` rather than appending to it, so root's gid `0` — which PID 1 (`s6-svscan`) does hold —
|
||||||
# image's job and it does it correctly. `render.py --selftest` asserts the ABSENCE of a pod
|
# is GONE, and anything Kubernetes supplies reaches s6-svscan and is discarded before Plex ever
|
||||||
# securityContext for this field precisely so nobody adds an inert one back later.
|
# starts. The group is the image's job and it does it correctly. `render.py --selftest` asserts the
|
||||||
|
# ABSENCE of a pod securityContext for this field precisely so nobody adds an inert one back later.
|
||||||
#
|
#
|
||||||
# `DirectoryOrCreate` IS THE SAFE FAILURE MODE, not laziness. A node that comes back without the
|
# THE LAST MILE IS A MANUAL OPERATOR STEP AND CANNOT LIVE IN THIS REPO. Handing over the device does
|
||||||
# device (rebuild, BIOS reset, driver regression, hardware swap) gets an empty directory and Plex
|
|
||||||
# degrades to SOFTWARE transcoding. `Directory` would fail the mount instead — and because
|
|
||||||
# `apps-wave-0` is `wait: true` with `apps-wave-1` `dependsOn` it, that would stall the WHOLE app
|
|
||||||
# tier for a feature that is not load-bearing. The price, stated plainly: a missing device is
|
|
||||||
# SILENT. This SD renders no `metricsEndpoint`, so there is no ServiceMonitor, no transcode-mode
|
|
||||||
# metric, and no alert that could ever fire. The only proof is an operator reading the Plex
|
|
||||||
# dashboard.
|
|
||||||
#
|
|
||||||
# THE LAST MILE IS A MANUAL OPERATOR STEP AND CANNOT LIVE IN THIS REPO. Mounting the device does
|
|
||||||
# not enable hardware transcoding — Plex's own `HardwareAcceleratedCodecs` preference does, and it
|
# not enable hardware transcoding — Plex's own `HardwareAcceleratedCodecs` preference does, and it
|
||||||
# is ABSENT from `Preferences.xml` today (the toggle has never been on). NO environment variable can
|
# is ABSENT from `Preferences.xml` today (the toggle has never been on). NO environment variable can
|
||||||
# set it: a grep of the running image's entire init surface (`/etc/cont-init.d`, `/etc/services.d`,
|
# set it: a grep of the running image's entire init surface (`/etc/cont-init.d`, `/etc/services.d`,
|
||||||
@@ -128,8 +344,7 @@ env:
|
|||||||
# and the six prefs the image does write (`MachineIdentifier`, `ProcessedMachineIdentifier`,
|
# and the six prefs the image does write (`MachineIdentifier`, `ProcessedMachineIdentifier`,
|
||||||
# `PlexOnlineToken`, `customConnections`, `allowedNetworks`, `TranscoderTempDirectory`) are all
|
# `PlexOnlineToken`, `customConnections`, `allowedNetworks`, `TranscoderTempDirectory`) are all
|
||||||
# unrelated. So there is no GitOps path to the toggle; an operator clicks it once in the web UI.
|
# unrelated. So there is no GitOps path to the toggle; an operator clicks it once in the web UI.
|
||||||
# `runbooks/plex-quicksync.md` is that procedure, plus how to verify it took and the
|
# `runbooks/plex-quicksync.md` is that procedure, plus how to verify it took.
|
||||||
# device-cgroup failure mode to expect first.
|
|
||||||
hardware:
|
hardware:
|
||||||
gpu: intel-quicksync
|
gpu: intel-quicksync
|
||||||
exposure: public # edge port-forward + public DNS; clients + Seerr reach it (docs/09; R-DNS/R-NET)
|
exposure: public # edge port-forward + public DNS; clients + Seerr reach it (docs/09; R-DNS/R-NET)
|
||||||
|
|||||||
@@ -448,6 +448,12 @@ auth:
|
|||||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||||
- platform-admins # operators of these services must not be locked out of them
|
- platform-admins # operators of these services must not be locked out of them
|
||||||
|
# NO `apiRoutes` HERE: UNMEASURED, not cleared. qBittorrent's WebUI drives everything through
|
||||||
|
# /api/v2/, so it is plausibly affected in the same way jackett is — but plausibility is what
|
||||||
|
# put a renderer constant on the table in the first place. MEASURED 2026-10-01 on the live pod
|
||||||
|
# (qbittorrent-5547bdb9bd-zq557, 0 restarts, 23h): 13,649 log lines, ZERO unauthenticated
|
||||||
|
# requests, nothing but health probes. No traffic means no evidence either way. Add `^/api/`
|
||||||
|
# when someone opens the WebUI and records the 302/401 split, as was done for jackett.
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line
|
range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ dependencies:
|
|||||||
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
||||||
port: 9117
|
port: 9117
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
- to: plex
|
||||||
|
port: 32400 # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
|
||||||
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: radarr.${BASE_DOMAIN}
|
- host: radarr.${BASE_DOMAIN}
|
||||||
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||||
@@ -112,6 +115,32 @@ auth:
|
|||||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||||
- platform-admins # operators of these services must not be locked out of them
|
- platform-admins # operators of these services must not be locked out of them
|
||||||
|
# Answer Radarr's machine endpoints with 401 instead of a 302 to Keycloak (schema 1.9.0;
|
||||||
|
# renders --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
|
||||||
|
#
|
||||||
|
# `^/signalr/` — MEASURED ON THE LIVE POD, 2026-10-01, 23h after the cookie fix deployed
|
||||||
|
# (radarr-7d55cbffb9-42hmh, 0 restarts): 232 unauthenticated requests in the pod's whole
|
||||||
|
# lifetime and ALL 232 are the same call — `POST /signalr/messages/negotiate` from a browser
|
||||||
|
# UA, answered 302 by the proxy with upstream `-`, i.e. Radarr never saw one of them. That is
|
||||||
|
# a left-open tab whose session the cookie rename orphaned, polling SignalR forever, silently
|
||||||
|
# redirected every time and NEVER re-prompting the operator. It is the reported symptom
|
||||||
|
# ("reloading never fixes it and I am never re-prompted") still running in production on a
|
||||||
|
# service nobody was watching. With 401 the UI's connection breaks LOUDLY, the operator
|
||||||
|
# reloads, and `/` is outside both regexes so the reload signs them back in.
|
||||||
|
#
|
||||||
|
# `^/api/` — measured on radarr, but on the PRE-FIX pod and NOT by me: the 2026-09-30
|
||||||
|
# diagnosis (decisions/2026-09-30-jackett-oauth2-proxy-session.md) recorded radarr's 156
|
||||||
|
# unauthenticated requests as `/api/v3/command` (62), `/api/v3/queue/details` (46) and
|
||||||
|
# `/signalr/messages/negotiate` (43). The current pod has logged ZERO /api/ traffic, so I did
|
||||||
|
# not re-measure it; it is included because 108 of 156 measured rejections on this app were
|
||||||
|
# /api/v3 XHRs, which is this app's own evidence rather than an analogy to another app.
|
||||||
|
#
|
||||||
|
# SCOPE: both are JSON/RPC namespaces a browser never NAVIGATES to. Radarr's UI is served at
|
||||||
|
# `/` with assets under /Content/ and /MediaCover/, none of which match — so a plain page load
|
||||||
|
# is still 302'd into a silent SSO sign-in.
|
||||||
|
apiRoutes:
|
||||||
|
- ^/api/
|
||||||
|
- ^/signalr/
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=5.0.0 <6.0.0" # stay on the Radarr v5 stable line
|
range: ">=5.0.0 <6.0.0" # stay on the Radarr v5 stable line
|
||||||
|
|||||||
+700
-91
@@ -156,21 +156,69 @@ OAUTH2_COOKIE_REFRESH = "2m30s"
|
|||||||
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
|
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
|
||||||
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
|
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
|
||||||
#
|
#
|
||||||
# THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED. A container port is not a
|
# THE SCRAPE WORKS AS OF 2026-10-05 — but it took THREE independent pieces, and this flag is
|
||||||
# Service port: these sidecars' Services expose the app port and :4180, and each namespace's
|
# only the first. A container port is not a Service port, and each namespace's default-deny
|
||||||
# default-deny baseline has `ingress: []`, so Prometheus can neither resolve nor reach this
|
# baseline denies all ingress, so the flag alone bought nothing for 63.7h:
|
||||||
# port until a Service port (or PodMonitor), a monitoring-ingress allow and a ServiceMonitor
|
# 1. this flag + a named `metrics` container port (PR #28)
|
||||||
# are added. Do not read this flag as "metrics are working".
|
# 2. a monitor that targets POD ports, not Service ports — platform/oauth2-proxy/podmonitor.yaml
|
||||||
|
# is a PodMonitor for exactly that reason (no Service exposes 44180)
|
||||||
|
# 3. an ingress allowance from ns monitoring (PR #49, r_monitoring_ingress_policy below)
|
||||||
|
# Verified on prod 2026-10-05: all 6 targets up == 1, scraping in 1-6ms.
|
||||||
|
# STILL do not read this flag alone as "metrics are working" — 1 without 3 is precisely the state
|
||||||
|
# that produced 5 x TargetDown for 63.7h. If you add a sidecar metrics port anywhere else, check
|
||||||
|
# that r_monitoring_ingress_policy covers it.
|
||||||
OAUTH2_PROXY_METRICS_PORT = 44180
|
OAUTH2_PROXY_METRICS_PORT = 44180
|
||||||
|
|
||||||
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
|
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
|
||||||
# The ServiceDefinition declares the CAPABILITY (schemas/servicedefinition.schema.json
|
# The ServiceDefinition declares the CAPABILITY (schemas/servicedefinition.schema.json
|
||||||
# `hardware.gpu`); everything below is the MECHANISM that currently satisfies it and is
|
# `hardware.gpu`); everything below is the MECHANISM that currently satisfies it and is
|
||||||
# deliberately confined to this file. Swapping to the Intel GPU device plugin
|
# deliberately confined to this file. This file is the ONLY place the mechanism appears,
|
||||||
# (`gpu.intel.com/i915` resource requests instead of a hostPath) is a change here and
|
# which is what let it be swapped on 2026-10-01 -- from a `/dev/dri` hostPath to the Intel
|
||||||
# nowhere else -- no catalog entry moves.
|
# GPU device plugin's extended resource -- with ZERO ServiceDefinition edits.
|
||||||
DRI_VOLUME_NAME = "dri"
|
#
|
||||||
DRI_DEVICE_DIR = "/dev/dri"
|
# The extended resource is advertised by the `intel-gpu-plugin` DaemonSet
|
||||||
|
# (platform/intel-gpu-plugin/, pinned intel/intel-gpu-plugin:0.37.0). MEASURED on all three
|
||||||
|
# prod nodes 2026-10-01: `.status.allocatable["gpu.intel.com/i915"] == 1`.
|
||||||
|
QUICKSYNC_RESOURCE = "gpu.intel.com/i915"
|
||||||
|
|
||||||
|
# --- API routes: 401 instead of 302, PER APP ------------------------------------------
|
||||||
|
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. Its built-in AJAX
|
||||||
|
# guard would send a 401 instead, but it fires only on an EXACT `Accept: application/json`,
|
||||||
|
# which these apps' JavaScript does not consistently send. So an unauthenticated XHR gets a
|
||||||
|
# 302, the browser follows it INVISIBLY, the live Keycloak SSO session satisfies it silently,
|
||||||
|
# and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
|
||||||
|
# prompt, no console error, nothing logged as a failure. On a WRITE it is worse: a browser
|
||||||
|
# following a 302 re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
|
||||||
|
# discarded and the UI reports success. MEASURED, twice:
|
||||||
|
# jackett 2026-09-30: 33 x 302 against 2 x 401 on /api/ paths, and 4 of 6 POSTs answered by
|
||||||
|
# the proxy and never reaching the app (access-log upstream field `-`, not `/`).
|
||||||
|
# radarr 2026-10-01, 23h after the cookie fix rolled: 232 x 302, EVERY ONE a
|
||||||
|
# `POST /signalr/messages/negotiate` from the operator's own browser, upstream `-`.
|
||||||
|
# A left-open tab whose cookie the rename orphaned, polling forever, silently
|
||||||
|
# redirected every time, NEVER re-prompting. That is the reported symptom
|
||||||
|
# ("reloading never fixes it and I am never re-prompted") still happening in
|
||||||
|
# production, on a service nobody was looking at.
|
||||||
|
# `--api-route=<regex>` makes matching paths answer 401.
|
||||||
|
#
|
||||||
|
# WHY THIS IS NOT A RENDERER CONSTANT, AND MUST NEVER BECOME ONE. The path shape is a property
|
||||||
|
# of the APP, not of the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
|
||||||
|
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One constant would apply one app's path shape to
|
||||||
|
# five, and the failure mode of a wrong one is not "no improvement" but "this service is
|
||||||
|
# permanently broken": a path a BROWSER NAVIGATES to, answered 401, shows a logged-out operator
|
||||||
|
# a bare error page and never offers a sign-in. So the routes are declared per service in
|
||||||
|
# `auth.apiRoutes` (schema 1.9.0) and rendered one `--api-route` per entry — the flag is
|
||||||
|
# repeatable, and it is SINGULAR: `--api-routes` does not exist in v7.8.1 and the proxy refuses
|
||||||
|
# to start with it (verified against the pinned binary's own --help, 2026-10-01:
|
||||||
|
# `--api-route strings return HTTP 401 instead of redirecting to authentication server...`).
|
||||||
|
#
|
||||||
|
# BREADTH RATCHET. Every entry must be anchored at `^/` and continue with literal path
|
||||||
|
# characters. That rejects `^/`, `^/.*`, `.*`, an unanchored `/api/`, and any alternation — the
|
||||||
|
# shapes that turn a page load into a 401. Alternation is refused rather than supported because
|
||||||
|
# the flag is repeatable, so one regex per route costs nothing and stays readable. The same
|
||||||
|
# pattern is in the schema (so ci/l0.sh's schema stage rejects it at the authoring surface) and
|
||||||
|
# enforced HERE (so ci/render-golden.sh fails even if the schema stage is skipped) — a breadth
|
||||||
|
# mistake must not need two independent gates to both be running.
|
||||||
|
OAUTH2_API_ROUTE_PATTERN = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -197,6 +245,12 @@ def apply_defaults(sd: dict) -> dict:
|
|||||||
if "mode" not in auth:
|
if "mode" not in auth:
|
||||||
auth["mode"] = "forward-auth" if s["ingressRoutes"] else "none"
|
auth["mode"] = "forward-auth" if s["ingressRoutes"] else "none"
|
||||||
auth.setdefault("groups", [])
|
auth.setdefault("groups", [])
|
||||||
|
# apiRoutes default posture is EMPTY, deliberately, and that is the whole point of the field
|
||||||
|
# (schema 1.9.0): an app gets --api-route only where someone MEASURED that it serves a machine
|
||||||
|
# API, so a newly-onboarded app renders no --api-route at all rather than inheriting another
|
||||||
|
# app's path shape. See OAUTH2_API_ROUTE_PATTERN above for why a default of `^/api/` would be
|
||||||
|
# wrong rather than merely generous.
|
||||||
|
auth.setdefault("apiRoutes", [])
|
||||||
s["auth"] = auth
|
s["auth"] = auth
|
||||||
|
|
||||||
update = dict(s.get("update") or {})
|
update = dict(s.get("update") or {})
|
||||||
@@ -224,7 +278,15 @@ def apply_defaults(sd: dict) -> dict:
|
|||||||
# object that actually runs on the cluster is not the one this SD renders (keycloak: the
|
# object that actually runs on the cluster is not the one this SD renders (keycloak: the
|
||||||
# Keycloak operator runs a StatefulSet, so a Deployment-shaped rule would `absent()` forever).
|
# Keycloak operator runs a StatefulSet, so a Deployment-shaped rule would `absent()` forever).
|
||||||
lw = dict(obs.get("livenessWorkload") or {})
|
lw = dict(obs.get("livenessWorkload") or {})
|
||||||
lw.setdefault("kind", "VirtualMachineInstance" if s["kind"] == "vm" else "Deployment")
|
# The default must track what is ACTUALLY rendered, or the `up` SLI silently dies: a
|
||||||
|
# Deployment-shaped rule against a StatefulSet `absent()`s forever, which is an alert that can
|
||||||
|
# never fire — the exact failure mode the keycloak override was added for. Deriving it from
|
||||||
|
# `availability.replicas` means bumping the replica count cannot leave a stale rule behind.
|
||||||
|
lw.setdefault(
|
||||||
|
"kind",
|
||||||
|
"VirtualMachineInstance" if s["kind"] == "vm"
|
||||||
|
else ("StatefulSet" if _is_statefulset(s) else "Deployment"),
|
||||||
|
)
|
||||||
lw.setdefault("name", s["name"])
|
lw.setdefault("name", s["name"])
|
||||||
obs["livenessWorkload"] = lw
|
obs["livenessWorkload"] = lw
|
||||||
s["observability"] = obs
|
s["observability"] = obs
|
||||||
@@ -427,6 +489,37 @@ def r_bulk_pvs(s: dict) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _replicas(s: dict) -> int:
|
||||||
|
"""Declared replica count (schema 1.10.0 `availability.replicas`). Default 1."""
|
||||||
|
return int((s.get("availability") or {}).get("replicas", 1) or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_statefulset(s: dict) -> bool:
|
||||||
|
"""True when this container workload must render a StatefulSet rather than a Deployment.
|
||||||
|
|
||||||
|
THE DECIDING CONSTRAINT IS THE VOLUME, not a preference. A longhorn storage entry renders
|
||||||
|
ReadWriteOnce (r_pvcs), and one RWO PVC cannot be attached by pods on two different nodes --
|
||||||
|
which is exactly what `availability.replicas > 1` plus the node anti-affinity below asks for.
|
||||||
|
A 2-replica Deployment over a shared RWO claim does not degrade, it simply never schedules the
|
||||||
|
second pod. A StatefulSet's volumeClaimTemplates give each replica its OWN claim, which is the
|
||||||
|
only shape that satisfies both at once.
|
||||||
|
|
||||||
|
A replicas-1 service keeps rendering a Deployment so every pre-existing golden is byte-identical.
|
||||||
|
"""
|
||||||
|
return s["kind"] == "container" and _replicas(s) > 1
|
||||||
|
|
||||||
|
|
||||||
|
def _sts_claim_volumes(s: dict) -> list:
|
||||||
|
"""Storage entries that become per-replica volumeClaimTemplates on a StatefulSet.
|
||||||
|
|
||||||
|
Only the dynamically-provisioned (longhorn) entries. A `bulk` entry is a STATIC, pre-bound NFS
|
||||||
|
PV over data that already exists outside the cluster and is shared read-only by every consumer
|
||||||
|
(r_bulk_pvs) -- per-replica copies of it are neither possible nor wanted, so it stays a
|
||||||
|
standalone PVC and an ordinary pod volume.
|
||||||
|
"""
|
||||||
|
return [v for v in s["storage"] if v.get("class", "longhorn") != "bulk"]
|
||||||
|
|
||||||
|
|
||||||
def r_pvcs(s: dict) -> list:
|
def r_pvcs(s: dict) -> list:
|
||||||
"""One PVC per storage entry (R-STATE-01/02/04).
|
"""One PVC per storage entry (R-STATE-01/02/04).
|
||||||
|
|
||||||
@@ -434,8 +527,15 @@ def r_pvcs(s: dict) -> list:
|
|||||||
bulk -> the STATIC external NFS PV rendered by r_bulk_pvs, pre-bound by volumeName.
|
bulk -> the STATIC external NFS PV rendered by r_bulk_pvs, pre-bound by volumeName.
|
||||||
"""
|
"""
|
||||||
out = []
|
out = []
|
||||||
|
# On a StatefulSet the longhorn claims are NOT standalone objects — they are
|
||||||
|
# volumeClaimTemplates, so each replica gets its own (see _is_statefulset). Emitting both would
|
||||||
|
# leave an orphan PVC that nothing mounts and that `prune` would later delete out from under
|
||||||
|
# nobody. `bulk` is unaffected and still rendered here.
|
||||||
|
sts = _is_statefulset(s)
|
||||||
for vol in s["storage"]:
|
for vol in s["storage"]:
|
||||||
cls = vol.get("class", "longhorn")
|
cls = vol.get("class", "longhorn")
|
||||||
|
if sts and cls != "bulk":
|
||||||
|
continue
|
||||||
if cls == "bulk":
|
if cls == "bulk":
|
||||||
# Mirror image of the PV: the same explicitly-empty class (so nothing dynamically
|
# Mirror image of the PV: the same explicitly-empty class (so nothing dynamically
|
||||||
# provisions), the same access mode, and volumeName completing the two-sided binding.
|
# provisions), the same access mode, and volumeName completing the two-sided binding.
|
||||||
@@ -516,6 +616,12 @@ def r_deployment(s: dict) -> dict:
|
|||||||
if vol.get("readOnly"):
|
if vol.get("readOnly"):
|
||||||
mount["readOnly"] = True
|
mount["readOnly"] = True
|
||||||
volume_mounts.append(mount)
|
volume_mounts.append(mount)
|
||||||
|
# On a StatefulSet a longhorn volume is supplied by its volumeClaimTemplate, whose
|
||||||
|
# metadata.name IS the pod volume name. Adding it to pod.volumes as well is a conflict
|
||||||
|
# (duplicate volume name), so it is skipped here and emitted below. The MOUNT is unchanged —
|
||||||
|
# the container cannot tell the difference, which is what keeps this transparent to the app.
|
||||||
|
if _is_statefulset(s) and vol.get("class", "longhorn") != "bulk":
|
||||||
|
continue
|
||||||
volumes.append(
|
volumes.append(
|
||||||
{
|
{
|
||||||
"name": vol["name"],
|
"name": vol["name"],
|
||||||
@@ -526,41 +632,59 @@ def r_deployment(s: dict) -> dict:
|
|||||||
# workload needs the host iGPU's render node for hardware video transcode. The SD says
|
# workload needs the host iGPU's render node for hardware video transcode. The SD says
|
||||||
# only that; the wiring below is the platform's current mechanism for satisfying it.
|
# only that; the wiring below is the platform's current mechanism for satisfying it.
|
||||||
#
|
#
|
||||||
# `type: DirectoryOrCreate` is DELIBERATE and load-bearing -- do NOT "tighten" it to
|
# THE MECHANISM IS AN EXTENDED RESOURCE, AND THE hostPath IT REPLACED WAS MEASURED NOT
|
||||||
# `Directory`. A node that comes back without the render device (rebuild, BIOS change,
|
# TO WORK. Do NOT "simplify" this back into a `/dev/dri` hostPath volume. That was the
|
||||||
# driver regression, hardware swap) must degrade the workload to SOFTWARE transcoding,
|
# original implementation (merged 2026-09-30) and it was proven broken in the live Plex
|
||||||
# not fail the mount: `apps-wave-0` is `wait: true` and `apps-wave-1` dependsOn it, so a
|
# pod on k3s-prod-2 on 2026-10-01: the device node was VISIBLE in the container
|
||||||
# failed hostPath mount stalls the entire app tier for the sake of a feature that is not
|
# (`crw-rw---- root video2 226,128 renderD128`) and could not be opened --
|
||||||
# load-bearing. `DirectoryOrCreate` turns that failure mode into "transcodes in
|
# `uid 0 O_RDWR -> EPERM` and `uid 1000 O_RDWR -> EPERM`. EPERM from the DEVICE CGROUP,
|
||||||
# software" instead of "the app tier does not reconcile". /dev is tmpfs, so the empty
|
# not EACCES from file permissions, proven three ways: root on the HOST opens the same
|
||||||
# directory the kubelet creates on such a node leaves nothing durable behind.
|
# device rc=0; the node is unified `cgroup2fs`; and the container's runc `config.json`
|
||||||
|
# read `linux.resources.devices: [{allow: False, access: 'rwm'}]` with
|
||||||
|
# `linux.devices: None` -- one deny-all rule, no allow entry for major 226. A hostPath is
|
||||||
|
# a filesystem operation: it binds the device node in but adds NO cgroup allow rule,
|
||||||
|
# because Kubernetes cannot infer one from a volume.
|
||||||
#
|
#
|
||||||
# The mount is deliberately NOT readOnly: VA-API opens the render node read-write.
|
# The device plugin is what produces the allow rule. Confirmed in the v0.37.0 source:
|
||||||
|
# `createDeviceSpecsFromDrmFiles` emits `DeviceSpec{..., Permissions: "rw"}`, which the
|
||||||
|
# kubelet turns into both the device injection and the matching cgroup allow.
|
||||||
|
#
|
||||||
|
# `limits`, NOT `requests`, AND THAT IS NOT A STYLE CHOICE: an extended resource must be
|
||||||
|
# expressed in `limits`. Kubernetes mirrors `requests` from `limits` automatically, and a
|
||||||
|
# requests-only extended resource is invalid. App container only, never a sidecar -- the
|
||||||
|
# oauth2-proxy sidecar does no transcoding.
|
||||||
|
#
|
||||||
|
# THE FAILURE MODE THIS TRADED INTO, STATED PLAINLY -- it is worse in blast radius, and
|
||||||
|
# unavoidable. BEFORE: a missing device meant `DirectoryOrCreate` mounted an empty dir,
|
||||||
|
# Plex ran and SILENTLY software-transcoded (invisible, but harmless to everything else).
|
||||||
|
# NOW: if the resource is unavailable (plugin broken, removed, or not yet reconciled) the
|
||||||
|
# pod is UNSCHEDULABLE, and because `apps-wave-0` is `wait: true` with `apps-wave-1`
|
||||||
|
# dependsOn it, that STALLS THE APP TIER. There is no graceful-degradation variant: an
|
||||||
|
# extended resource is a hard scheduling constraint, and it is also the only thing that
|
||||||
|
# produces the cgroup allow rule. Plex's schedulability is now coupled to the
|
||||||
|
# intel-gpu-plugin DaemonSet; that is the price of hardware transcoding working at all.
|
||||||
|
# ESCAPE HATCH (one field, no code change): delete the `hardware:` block from
|
||||||
|
# `catalog/plex.yaml` and re-render -- Plex returns to software transcoding and the tier
|
||||||
|
# unblocks. runbooks/plex-quicksync.md carries it.
|
||||||
#
|
#
|
||||||
# `podSecurityContext.supplementalGroups` is DELIBERATELY NOT SET here, and adding it
|
# `podSecurityContext.supplementalGroups` is DELIBERATELY NOT SET here, and adding it
|
||||||
# would be a regression, not a hardening. MEASURED in the running container on prod-2,
|
# would be a regression, not a hardening. MEASURED in the running container on prod-2
|
||||||
# 2026-09-30: the image's own cont-init hook
|
# (2026-09-30, re-measured 2026-10-01): the image's own cont-init hook
|
||||||
# `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` `find`s every char device
|
# `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` `find`s every char device
|
||||||
# under /dev/dri, `stat -c '%g'` each one's GID, `groupadd`s a matching group INSIDE the
|
# under /dev/dri, `stat -c '%g'` each one's GID, `groupadd`s a matching group INSIDE the
|
||||||
# container when none exists, and `usermod -a -G` the app user into it -- every start,
|
# container when none exists, and `usermod -a -G` the app user into it -- every start,
|
||||||
# nothing hardcoded. cont-init.d completes before services.d, so the account database
|
# nothing hardcoded. It does exactly the same thing to the PLUGIN-INJECTED device, which
|
||||||
# s6 reads at exec time already carries the group. The service is then started with
|
# is why this swap needs no group work either. cont-init.d completes before services.d,
|
||||||
# `exec s6-setuidgid plex ...`, and `s6-setuidgid` REPLACES the process group list from
|
# so the account database s6 reads at exec time already carries the group: measured live,
|
||||||
# /etc/group rather than appending to it: `Plex Media Server` runs with
|
# `Plex Media Server` ran with `Groups: 44 100 992 1000`, where 992 is the host `render`
|
||||||
# `Uid: 1000 Gid: 1000 Groups: 100 1000` -- root's gid 0, which PID 1 (s6-svscan) does
|
# GID. The service is started with `exec s6-setuidgid plex ...`, and `s6-setuidgid`
|
||||||
# have, is GONE. Anything Kubernetes puts in supplementalGroups reaches s6-svscan and is
|
# REPLACES the process group list from /etc/group rather than appending to it, so
|
||||||
# then discarded before the app ever starts. MOUNTING THE DEVICE IS SUFFICIENT; the
|
# anything Kubernetes puts in supplementalGroups reaches s6-svscan and is discarded
|
||||||
# group handling is the image's job and it does it correctly. A supplementalGroups entry
|
# before the app ever starts. A supplementalGroups entry here would be inert AND would
|
||||||
# here would be inert AND would mislead whoever debugs this next.
|
# mislead whoever debugs this next.
|
||||||
hw_gpu = (s.get("hardware") or {}).get("gpu")
|
hw_gpu = (s.get("hardware") or {}).get("gpu")
|
||||||
if hw_gpu == "intel-quicksync":
|
if hw_gpu == "intel-quicksync":
|
||||||
volume_mounts.append({"name": DRI_VOLUME_NAME, "mountPath": DRI_DEVICE_DIR})
|
container["resources"] = {"limits": {QUICKSYNC_RESOURCE: 1}}
|
||||||
volumes.append(
|
|
||||||
{
|
|
||||||
"name": DRI_VOLUME_NAME,
|
|
||||||
"hostPath": {"path": DRI_DEVICE_DIR, "type": "DirectoryOrCreate"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if volume_mounts:
|
if volume_mounts:
|
||||||
container["volumeMounts"] = volume_mounts
|
container["volumeMounts"] = volume_mounts
|
||||||
@@ -595,20 +719,80 @@ def r_deployment(s: dict) -> dict:
|
|||||||
if ann:
|
if ann:
|
||||||
tmpl_meta["annotations"] = ann
|
tmpl_meta["annotations"] = ann
|
||||||
|
|
||||||
spec = {"replicas": 1}
|
sts = _is_statefulset(s)
|
||||||
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
|
||||||
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
# NODE ANTI-AFFINITY, and it is REQUIRED rather than preferred. `availability.replicas > 1`
|
||||||
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
# exists to survive the loss of one node; `preferred` would let both replicas land on the same
|
||||||
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
# node and quietly deliver none of that, with every object reporting healthy. The cost is real
|
||||||
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
# and is accepted: with N replicas the workload needs N schedulable nodes, so on a 3-node
|
||||||
if s["storage"]:
|
# cluster losing TWO nodes leaves a replica Pending and — because `apps-wave-0` is `wait: true`
|
||||||
|
# with `apps-wave-1` dependsOn it — STALLS THE APP TIER. That coincides with having already lost
|
||||||
|
# etcd quorum on a 3-node control plane, i.e. the cluster is not reconciling anything at that
|
||||||
|
# point anyway, so the stall is a symptom rather than a new failure.
|
||||||
|
if sts:
|
||||||
|
pod_spec["affinity"] = {
|
||||||
|
"podAntiAffinity": {
|
||||||
|
"requiredDuringSchedulingIgnoredDuringExecution": [
|
||||||
|
{
|
||||||
|
"labelSelector": {
|
||||||
|
"matchLabels": {"app.kubernetes.io/name": s["name"]}
|
||||||
|
},
|
||||||
|
"topologyKey": "kubernetes.io/hostname",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = {"replicas": _replicas(s)}
|
||||||
|
if sts:
|
||||||
|
# serviceName is a required StatefulSet field. It points at the service rendered by
|
||||||
|
# r_service; that Service is a normal ClusterIP rather than headless, so per-pod DNS
|
||||||
|
# (<pod>.<svc>) does NOT resolve — and nothing here needs it. These replicas are
|
||||||
|
# interchangeable resolvers behind one VIP, not peers that address each other.
|
||||||
|
spec["serviceName"] = s["name"]
|
||||||
|
# Parallel, not the OrderedReady default. OrderedReady starts replica N+1 only after N is
|
||||||
|
# Ready, so a cold start costs the sum of every replica's first-boot work instead of the
|
||||||
|
# max — and `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between
|
||||||
|
# replicas to protect (no leader, no migration, no shared volume), so serialising buys
|
||||||
|
# nothing and risks the wave timing out on a rebuild.
|
||||||
|
spec["podManagementPolicy"] = "Parallel"
|
||||||
|
elif s["storage"]:
|
||||||
|
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
||||||
|
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
||||||
|
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
||||||
|
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
||||||
|
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
||||||
|
#
|
||||||
|
# A StatefulSet needs none of this and cannot express it: each replica owns its own claim,
|
||||||
|
# so a rolling update hands nothing over, and `strategy` is not a StatefulSet field at all.
|
||||||
spec["strategy"] = {"type": "Recreate"}
|
spec["strategy"] = {"type": "Recreate"}
|
||||||
spec["selector"] = {"matchLabels": {"app.kubernetes.io/name": s["name"]}}
|
spec["selector"] = {"matchLabels": {"app.kubernetes.io/name": s["name"]}}
|
||||||
spec["template"] = {"metadata": tmpl_meta, "spec": pod_spec}
|
spec["template"] = {"metadata": tmpl_meta, "spec": pod_spec}
|
||||||
|
if sts:
|
||||||
|
# Per-replica claims. metadata.name MUST equal the pod volume name the container mounts
|
||||||
|
# (see the volumes loop above); the resulting PVCs are <name>-<sts>-<ordinal>, e.g.
|
||||||
|
# config-pihole-0. Same tier and access mode a standalone longhorn PVC gets from r_pvcs —
|
||||||
|
# this changes WHO owns the claim, never what it is.
|
||||||
|
tmpls = []
|
||||||
|
for vol in _sts_claim_volumes(s):
|
||||||
|
vspec = {"accessModes": ["ReadWriteOnce"], "storageClassName": "longhorn"}
|
||||||
|
if vol.get("size"):
|
||||||
|
vspec["resources"] = {"requests": {"storage": vol["size"]}}
|
||||||
|
tmpls.append(
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": vol["name"],
|
||||||
|
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||||
|
},
|
||||||
|
"spec": vspec,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if tmpls:
|
||||||
|
spec["volumeClaimTemplates"] = tmpls
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"apiVersion": "apps/v1",
|
"apiVersion": "apps/v1",
|
||||||
"kind": "Deployment",
|
"kind": "StatefulSet" if sts else "Deployment",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": s["name"],
|
"name": s["name"],
|
||||||
"namespace": s["name"],
|
"namespace": s["name"],
|
||||||
@@ -618,6 +802,35 @@ def r_deployment(s: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def r_pdb(s: dict):
|
||||||
|
"""PodDisruptionBudget for a multi-replica workload (availability.replicas > 1).
|
||||||
|
|
||||||
|
minAvailable 1, not `maxUnavailable: 1`. The budget's job here is to stop a VOLUNTARY
|
||||||
|
disruption — a node drain for maintenance, a cluster upgrade — from taking the last replica
|
||||||
|
with it; `minAvailable: 1` says that whatever else happens, one must keep serving. It does
|
||||||
|
NOT protect against involuntary disruption (a node dying), which is what the anti-affinity
|
||||||
|
and the replica count are for.
|
||||||
|
|
||||||
|
Rendered only when replicas > 1: a PDB over a single-replica workload is actively harmful, as
|
||||||
|
`minAvailable: 1` would then block every drain of its node forever.
|
||||||
|
"""
|
||||||
|
if not _is_statefulset(s):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"apiVersion": "policy/v1",
|
||||||
|
"kind": "PodDisruptionBudget",
|
||||||
|
"metadata": {
|
||||||
|
"name": s["name"],
|
||||||
|
"namespace": s["name"],
|
||||||
|
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"minAvailable": 1,
|
||||||
|
"selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def r_service(s: dict):
|
def r_service(s: dict):
|
||||||
ports = _service_ports(s)
|
ports = _service_ports(s)
|
||||||
# A declared metrics endpoint becomes a NAMED Service port, because that name is the only
|
# A declared metrics endpoint becomes a NAMED Service port, because that name is the only
|
||||||
@@ -1054,7 +1267,12 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
|||||||
|
|
||||||
--cookie-name and --cookie-domain are DERIVED per app (see the block comment on those two
|
--cookie-name and --cookie-domain are DERIVED per app (see the block comment on those two
|
||||||
args): every instance holds a different cookie-secret, so sharing one cookie name across the
|
args): every instance holds a different cookie-secret, so sharing one cookie name across the
|
||||||
zone makes the instances evict each other's sessions."""
|
zone makes the instances evict each other's sessions.
|
||||||
|
|
||||||
|
--api-route is DECLARED per app, from auth.apiRoutes, and is the one flag here that is
|
||||||
|
neither a constant nor derivable: it names the paths THIS app serves as a machine API, and
|
||||||
|
those differ per app (radarr `/signalr/`, jackett `/api/`, pihole none). An app that declares
|
||||||
|
none renders no --api-route at all."""
|
||||||
route = s["ingressRoutes"][0]
|
route = s["ingressRoutes"][0]
|
||||||
host = route["host"]
|
host = route["host"]
|
||||||
upstream_port = route["port"]
|
upstream_port = route["port"]
|
||||||
@@ -1074,6 +1292,27 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
|||||||
args += [
|
args += [
|
||||||
"--skip-provider-button=true",
|
"--skip-provider-button=true",
|
||||||
f"--redirect-url=https://{host}/oauth2/callback",
|
f"--redirect-url=https://{host}/oauth2/callback",
|
||||||
|
]
|
||||||
|
# ── API ROUTES: 401 instead of 302, ON THIS APP'S OWN PATHS ONLY ───────────────────────
|
||||||
|
# One flag per declared route, in declared order. An app that declares NONE renders NO
|
||||||
|
# --api-route, which is the correct and common case — see OAUTH2_API_ROUTE_PATTERN above for
|
||||||
|
# the measurements, for why this cannot be a renderer constant, and for the breadth ratchet.
|
||||||
|
# Validated here as well as in the schema so a breadth mistake is caught by render-golden
|
||||||
|
# even on a run where the schema stage did not execute.
|
||||||
|
for route in s["auth"].get("apiRoutes", []):
|
||||||
|
if not OAUTH2_API_ROUTE_PATTERN.match(str(route)):
|
||||||
|
raise SystemExit(
|
||||||
|
f"render.py: {s['name']}: auth.apiRoutes entry {route!r} is not an anchored path "
|
||||||
|
f"regex. Each entry must start with '^/' and continue with literal path "
|
||||||
|
f"characters (e.g. '^/api/', '^/signalr/', '^/api/v3/.*'). Rejected shapes: '^/' "
|
||||||
|
f"and '^/.*' (match every page load), an unanchored '/api/' (matches mid-path), "
|
||||||
|
f"and alternation (the flag is repeatable — declare one entry per route). A "
|
||||||
|
f"too-broad --api-route answers a PAGE LOAD with 401, so a logged-out operator "
|
||||||
|
f"sees a bare error page and is never offered a sign-in: the service looks "
|
||||||
|
f"permanently broken rather than merely unauthenticated."
|
||||||
|
)
|
||||||
|
args.append(f"--api-route={route}")
|
||||||
|
args += [
|
||||||
# ── COOKIE ISOLATION (fleet fix 2026-09-30; crafty's 2026-09-29 fix, generalized) ──────
|
# ── COOKIE ISOLATION (fleet fix 2026-09-30; crafty's 2026-09-29 fix, generalized) ──────
|
||||||
# EVERY oauth2-proxy on this platform — this sidecar, its four siblings, and the SHARED
|
# EVERY oauth2-proxy on this platform — this sidecar, its four siblings, and the SHARED
|
||||||
# forward-auth proxy (platform/oauth2-proxy) — is an INDEPENDENT instance holding its OWN
|
# forward-auth proxy (platform/oauth2-proxy) — is an INDEPENDENT instance holding its OWN
|
||||||
@@ -1129,8 +1368,9 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
|||||||
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
|
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
|
||||||
"--pass-access-token=true",
|
"--pass-access-token=true",
|
||||||
"--set-xauthrequest=true",
|
"--set-xauthrequest=true",
|
||||||
# Start the /metrics listener. Prerequisite only — not yet reachable by Prometheus;
|
# Start the /metrics listener. Scraped as of 2026-10-05, but only because
|
||||||
# see OAUTH2_PROXY_METRICS_PORT above for exactly what is still missing.
|
# r_monitoring_ingress_policy also opens this port to ns monitoring — the flag alone was
|
||||||
|
# inert for 63.7h. See OAUTH2_PROXY_METRICS_PORT above for the three pieces required.
|
||||||
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
|
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
|
||||||
]
|
]
|
||||||
secret = f"{s['name']}-oauth2-proxy"
|
secret = f"{s['name']}-oauth2-proxy"
|
||||||
@@ -1308,12 +1548,54 @@ def r_monitoring_ingress_policy(s: dict):
|
|||||||
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
|
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
|
||||||
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
|
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
|
||||||
|
|
||||||
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
|
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
|
||||||
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||||
benefit.
|
benefit.
|
||||||
|
|
||||||
|
TWO INDEPENDENT SOURCES OF A SERVED METRICS PORT, and this used to cover only the first:
|
||||||
|
|
||||||
|
1. `observability.metricsEndpoint` — the APP's own exporter. NOTE, measured 2026-10-04: no
|
||||||
|
ServiceDefinition in this catalog declares one, so before the sidecar clause below this
|
||||||
|
function had NEVER emitted a single object on any branch. It is correct but, so far,
|
||||||
|
entirely latent — do not read its presence as evidence that scrape ingress is solved.
|
||||||
|
|
||||||
|
2. THE forward-auth GATE SIDECAR's own `/metrics` on 44180 (added 2026-10-04). Every
|
||||||
|
`_needs_oauth2_sidecar` app gets `--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}`
|
||||||
|
and a named `metrics` container port from `_oauth2_sidecar_container`, so the port is
|
||||||
|
served whenever the sidecar exists — unconditionally, which is what makes it safe to
|
||||||
|
open without a per-app declaration.
|
||||||
|
|
||||||
|
WHY CLAUSE 2 KEYS ON `_needs_oauth2_sidecar` AND NOT ON `observability.metrics`: it must match
|
||||||
|
the condition the SCRAPE keys on, exactly. platform/oauth2-proxy/podmonitor.yaml selects pods
|
||||||
|
by the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL and nothing else, so a
|
||||||
|
target exists iff the sidecar exists. Keying the allowance on `observability.metrics` instead
|
||||||
|
would let `metrics: false` produce a discovered-but-blocked target — the same "two halves
|
||||||
|
authored apart" failure this function exists to prevent, just moved one field over.
|
||||||
|
|
||||||
|
THIS CLAUSE IS A BUG FIX, and the bug was live for 63.7h. Measured on prod 2026-10-04: all 6
|
||||||
|
sidecar targets across jackett/radarr/sonarr/qbittorrent/pihole read `health=down`,
|
||||||
|
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly 10.0s — the scrapeTimeout,
|
||||||
|
i.e. packets DROPPED, not refused. Each app namespace ships a `default-deny` NetworkPolicy
|
||||||
|
with `policyTypes: [Ingress, Egress]` and NO `ingress` key at all (deny-all-ingress), and the
|
||||||
|
only ingress allowance is `<app>-allow-gateway-ingress`: `fromEntities: [ingress]` -> 4180.
|
||||||
|
Prometheus matched no clause. 5 x `TargetDown` + 6 x `OAuth2ProxyGateUnobserved` had been
|
||||||
|
re-notifying at ~22 emails/day since the PodMonitor landed on 2026-10-02.
|
||||||
|
|
||||||
|
WHY THE POLICY BELONGS HERE AND NOT IN platform/oauth2-proxy: podmonitor.yaml's header records
|
||||||
|
the objection — a policy in that tier would name app namespaces created by the SIBLING
|
||||||
|
apps-wave-0/1 tiers (measured Flux graph: `oauth2-proxy` and `apps-wave-0` both
|
||||||
|
`dependsOn=[platform-config]`, no ordering between them), so it would apply on a running
|
||||||
|
cluster and fail on a cold bootstrap. Rendered here it ships in the same tier that creates the
|
||||||
|
namespace, which dissolves that ordering problem entirely.
|
||||||
"""
|
"""
|
||||||
ep = s["observability"].get("metricsEndpoint")
|
obs = s["observability"]
|
||||||
if not ep or not s["observability"]["metrics"]:
|
ep = obs.get("metricsEndpoint")
|
||||||
|
ports: list[str] = []
|
||||||
|
if ep and obs["metrics"]:
|
||||||
|
ports.append(str(ep["port"]))
|
||||||
|
if _needs_oauth2_sidecar(s):
|
||||||
|
ports.append(str(OAUTH2_PROXY_METRICS_PORT))
|
||||||
|
if not ports:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"apiVersion": "cilium.io/v2",
|
"apiVersion": "cilium.io/v2",
|
||||||
@@ -1331,7 +1613,7 @@ def r_monitoring_ingress_policy(s: dict):
|
|||||||
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
|
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
|
||||||
],
|
],
|
||||||
"toPorts": [
|
"toPorts": [
|
||||||
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
|
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1725,7 +2007,13 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None)
|
|||||||
)
|
)
|
||||||
for pvc in r_pvcs(s):
|
for pvc in r_pvcs(s):
|
||||||
docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc))
|
docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc))
|
||||||
docs.append(("Deployment (R-PLAT-02)", r_deployment(s)))
|
wl = r_deployment(s)
|
||||||
|
docs.append((f"{wl['kind']} (R-PLAT-02)", wl))
|
||||||
|
pdb = r_pdb(s)
|
||||||
|
if pdb:
|
||||||
|
docs.append(
|
||||||
|
("PodDisruptionBudget — keep one replica through a drain (availability.replicas)", pdb)
|
||||||
|
)
|
||||||
svc = r_service(s)
|
svc = r_service(s)
|
||||||
if svc:
|
if svc:
|
||||||
docs.append(("Service", svc))
|
docs.append(("Service", svc))
|
||||||
@@ -1910,6 +2198,24 @@ EXTRA_UNITS: dict = {
|
|||||||
"pihole-dns.yaml",
|
"pihole-dns.yaml",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
# Plex's remote-access exposure: the raw tcp/32400 LoadBalancer Service on a pinned VIP, plus the
|
||||||
|
# CiliumNetworkPolicy that admits off-cluster clients to it (apps/wave-0/plex-remote.yaml).
|
||||||
|
# `above: False` co-locates it in plex's OWN wave — these are the plex pod's own Service and its
|
||||||
|
# own policy, applied in the same wave as the Deployment they select, exactly as the rendered
|
||||||
|
# Service and CNPs in plex.yaml are. Nothing in a higher wave gates on it.
|
||||||
|
#
|
||||||
|
# It is a non-catalog unit for the same reason pihole-dns is: the ServiceDefinition schema cannot
|
||||||
|
# express a LoadBalancer Service or a pinned lbipam VIP (additionalProperties is false, and
|
||||||
|
# `ingressRoutes` only renders HTTPRoutes on the L7 Gateway, which cannot carry Plex's raw port).
|
||||||
|
# Plex's own settings stay in the catalog as env on catalog/plex.yaml; the only thing living
|
||||||
|
# outside it is the exposure the schema has no word for.
|
||||||
|
"plex-remote": {
|
||||||
|
"anchor": "plex",
|
||||||
|
"above": False,
|
||||||
|
"files": [
|
||||||
|
"plex-remote.yaml",
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2156,6 +2462,132 @@ def _selftest() -> int:
|
|||||||
check("Namespace" in kinds, "container: missing Namespace")
|
check("Namespace" in kinds, "container: missing Namespace")
|
||||||
check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy")
|
check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy")
|
||||||
check("Deployment" in kinds, "container: missing Deployment")
|
check("Deployment" in kinds, "container: missing Deployment")
|
||||||
|
check(
|
||||||
|
"PodDisruptionBudget" not in kinds,
|
||||||
|
"replicas-1 container must render NO PodDisruptionBudget (minAvailable 1 over a single "
|
||||||
|
"replica blocks every drain of its node forever)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1b) availability.replicas (schema 1.10.0). The DEFAULT path must be untouched, and the >1
|
||||||
|
# path must change workload kind, claim ownership, spreading, budget AND the SLI metric
|
||||||
|
# together — each of these has a silent-failure mode if it is the one that gets forgotten.
|
||||||
|
base_av = {
|
||||||
|
"name": "av",
|
||||||
|
"source": {"image": "nginx:1"},
|
||||||
|
"storage": [{"name": "config", "size": "1Gi"}],
|
||||||
|
"observability": {"metrics": True, "slis": ["up"]},
|
||||||
|
}
|
||||||
|
one = render({**base_av, "availability": {"replicas": 1}})
|
||||||
|
one_kinds = [d[1]["kind"] for d in one]
|
||||||
|
check(
|
||||||
|
"Deployment" in one_kinds and "StatefulSet" not in one_kinds,
|
||||||
|
"availability.replicas=1 must still render a Deployment (back-compat)",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"PersistentVolumeClaim" in one_kinds,
|
||||||
|
"availability.replicas=1 must still render a standalone PVC",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"PodDisruptionBudget" not in one_kinds,
|
||||||
|
"availability.replicas=1 must render no PodDisruptionBudget",
|
||||||
|
)
|
||||||
|
|
||||||
|
many = render({**base_av, "availability": {"replicas": 2}})
|
||||||
|
many_kinds = [d[1]["kind"] for d in many]
|
||||||
|
check(
|
||||||
|
"StatefulSet" in many_kinds and "Deployment" not in many_kinds,
|
||||||
|
"availability.replicas>1 must render a StatefulSet, not a Deployment (a shared RWO claim "
|
||||||
|
"cannot be attached on two nodes, so a multi-replica Deployment never schedules)",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"PersistentVolumeClaim" not in many_kinds,
|
||||||
|
"availability.replicas>1 must NOT also render a standalone longhorn PVC — it would be an "
|
||||||
|
"orphan that nothing mounts",
|
||||||
|
)
|
||||||
|
sts = next(d[1] for d in many if d[1]["kind"] == "StatefulSet")
|
||||||
|
check(sts["spec"].get("replicas") == 2, "StatefulSet must carry the declared replica count")
|
||||||
|
check(
|
||||||
|
"strategy" not in sts["spec"],
|
||||||
|
"StatefulSet must not carry `strategy` (not a StatefulSet field; Recreate is a "
|
||||||
|
"Deployment-only workaround for handing over one RWO volume)",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
sts["spec"].get("podManagementPolicy") == "Parallel",
|
||||||
|
"StatefulSet must set podManagementPolicy=Parallel — OrderedReady serialises first-boot "
|
||||||
|
"work across replicas and apps-wave-0 has a 5m timeout",
|
||||||
|
)
|
||||||
|
check(sts["spec"].get("serviceName") == "av", "StatefulSet must set serviceName")
|
||||||
|
tmpls = sts["spec"].get("volumeClaimTemplates") or []
|
||||||
|
check(
|
||||||
|
[t["metadata"]["name"] for t in tmpls] == ["config"],
|
||||||
|
"StatefulSet volumeClaimTemplate name must equal the pod volume name the container mounts, "
|
||||||
|
"or the pod gets a duplicate/unresolvable volume",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
tmpls and tmpls[0]["spec"]["accessModes"] == ["ReadWriteOnce"]
|
||||||
|
and tmpls[0]["spec"]["storageClassName"] == "longhorn",
|
||||||
|
"StatefulSet claim template must keep the same tier/access mode as the standalone PVC",
|
||||||
|
)
|
||||||
|
sts_vols = {v["name"] for v in sts["spec"]["template"]["spec"].get("volumes", [])}
|
||||||
|
check(
|
||||||
|
"config" not in sts_vols,
|
||||||
|
"a claim-template volume must NOT also appear in pod.volumes (duplicate volume name)",
|
||||||
|
)
|
||||||
|
mounts = sts["spec"]["template"]["spec"]["containers"][0].get("volumeMounts") or []
|
||||||
|
check(
|
||||||
|
any(m["name"] == "config" for m in mounts),
|
||||||
|
"the container must still mount the volume by name on a StatefulSet (transparent to the app)",
|
||||||
|
)
|
||||||
|
aff = (sts["spec"]["template"]["spec"].get("affinity") or {}).get("podAntiAffinity") or {}
|
||||||
|
req = aff.get("requiredDuringSchedulingIgnoredDuringExecution") or []
|
||||||
|
check(
|
||||||
|
req and req[0].get("topologyKey") == "kubernetes.io/hostname",
|
||||||
|
"availability.replicas>1 must render REQUIRED per-node anti-affinity — `preferred` can put "
|
||||||
|
"every replica on one node and deliver none of the intended node-failure tolerance",
|
||||||
|
)
|
||||||
|
pdbs = [d[1] for d in many if d[1]["kind"] == "PodDisruptionBudget"]
|
||||||
|
check(len(pdbs) == 1, "availability.replicas>1 must render exactly one PodDisruptionBudget")
|
||||||
|
check(
|
||||||
|
pdbs and pdbs[0]["spec"].get("minAvailable") == 1,
|
||||||
|
"PodDisruptionBudget must be minAvailable=1 (keep one replica serving through a drain)",
|
||||||
|
)
|
||||||
|
pr_many = next((d[1] for d in many if d[1]["kind"] == "PrometheusRule"), None)
|
||||||
|
check(
|
||||||
|
pr_many is not None
|
||||||
|
and "kube_statefulset_status_replicas_ready"
|
||||||
|
in pr_many["spec"]["groups"][0]["rules"][0]["expr"],
|
||||||
|
"the up SLI must follow the rendered workload kind — a Deployment-shaped rule against a "
|
||||||
|
"StatefulSet absent()s forever and can never fire",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A bulk volume is shared external NFS: it must stay a standalone PVC even on a StatefulSet.
|
||||||
|
mixed = render(
|
||||||
|
{
|
||||||
|
**base_av,
|
||||||
|
"availability": {"replicas": 2},
|
||||||
|
"storage": [
|
||||||
|
{"name": "config", "size": "1Gi"},
|
||||||
|
{"name": "media", "class": "bulk", "export": "/mnt/x", "size": "1Ti"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
m_sts = next(d[1] for d in mixed if d[1]["kind"] == "StatefulSet")
|
||||||
|
check(
|
||||||
|
[t["metadata"]["name"] for t in m_sts["spec"]["volumeClaimTemplates"]] == ["config"],
|
||||||
|
"only longhorn volumes become claim templates; a bulk share must not be per-replica",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
any(
|
||||||
|
d[1]["kind"] == "PersistentVolumeClaim"
|
||||||
|
and d[1]["metadata"]["name"] == "av-media"
|
||||||
|
for d in mixed
|
||||||
|
),
|
||||||
|
"a bulk PVC must still be rendered standalone on a StatefulSet",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"media" in {v["name"] for v in m_sts["spec"]["template"]["spec"].get("volumes", [])},
|
||||||
|
"a bulk volume must still appear in pod.volumes on a StatefulSet",
|
||||||
|
)
|
||||||
|
|
||||||
ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace")
|
ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace")
|
||||||
check(
|
check(
|
||||||
@@ -2579,10 +3011,13 @@ def _selftest() -> int:
|
|||||||
"an SD without podSecurityContext must not gain a pod securityContext",
|
"an SD without podSecurityContext must not gain a pod securityContext",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6b) hardware.gpu: intel-quicksync -> a /dev/dri hostPath volume and a read-write mount
|
# 6b) hardware.gpu: intel-quicksync -> a `gpu.intel.com/i915` LIMIT on the app container
|
||||||
# on the app container, and NOTHING ELSE. The SD declares the CAPABILITY; these
|
# and NOTHING ELSE: no volume, no volumeMount, no securityContext, no nodeSelector.
|
||||||
# assertions pin the MECHANISM that currently satisfies it -- including what it
|
# The SD declares the CAPABILITY; these assertions pin the MECHANISM that currently
|
||||||
# deliberately does NOT emit -- so a change to it is a deliberate, visible edit.
|
# satisfies it -- including what it deliberately does NOT emit -- so a change to it
|
||||||
|
# is a deliberate, visible edit. The previous mechanism (a /dev/dri hostPath) is
|
||||||
|
# asserted ABSENT: it was measured not to work (EPERM from the device cgroup; see
|
||||||
|
# the emission site), and the absence assertion is what stops it coming back.
|
||||||
hw_docs = render(
|
hw_docs = render(
|
||||||
{
|
{
|
||||||
"name": "hw",
|
"name": "hw",
|
||||||
@@ -2593,24 +3028,36 @@ def _selftest() -> int:
|
|||||||
hw_pod = next(
|
hw_pod = next(
|
||||||
d[1] for d in hw_docs if d[1]["kind"] == "Deployment"
|
d[1] for d in hw_docs if d[1]["kind"] == "Deployment"
|
||||||
)["spec"]["template"]["spec"]
|
)["spec"]["template"]["spec"]
|
||||||
|
hw_app = hw_pod["containers"][0]
|
||||||
|
# The whole mechanism, exactly: an extended resource in `limits`. `requests` is NOT set
|
||||||
|
# by the renderer -- Kubernetes mirrors it from `limits`, and a requests-only extended
|
||||||
|
# resource is invalid. Asserting the exact dict is deliberate: it fails if anyone adds a
|
||||||
|
# CPU/memory resources feature through this field (that would be scope creep, and this
|
||||||
|
# field is about the DEVICE only).
|
||||||
check(
|
check(
|
||||||
{
|
hw_app.get("resources") == {"limits": {"gpu.intel.com/i915": 1}},
|
||||||
"name": "dri",
|
"hardware.gpu=intel-quicksync must render resources.limits "
|
||||||
"hostPath": {"path": "/dev/dri", "type": "DirectoryOrCreate"},
|
"{gpu.intel.com/i915: 1} on the app container, and nothing else in resources",
|
||||||
}
|
|
||||||
in hw_pod.get("volumes", []),
|
|
||||||
"hardware.gpu=intel-quicksync must render a /dev/dri hostPath volume "
|
|
||||||
"with type DirectoryOrCreate (a GPU-less node must degrade, not fail the mount)",
|
|
||||||
)
|
)
|
||||||
hw_mounts = hw_pod["containers"][0].get("volumeMounts", [])
|
# The retired mechanism must stay retired. A hostPath binds the device node in but adds
|
||||||
|
# no device-cgroup allow rule, so the open() was EPERM with the node plainly visible.
|
||||||
check(
|
check(
|
||||||
{"name": "dri", "mountPath": "/dev/dri"} in hw_mounts,
|
not any(
|
||||||
"hardware.gpu=intel-quicksync must mount /dev/dri READ-WRITE on the app container",
|
"hostPath" in v or v.get("name") == "dri" for v in hw_pod.get("volumes", [])
|
||||||
|
)
|
||||||
|
and not any(
|
||||||
|
m.get("mountPath") == "/dev/dri" or m.get("name") == "dri"
|
||||||
|
for m in hw_app.get("volumeMounts", [])
|
||||||
|
),
|
||||||
|
"hardware.gpu=intel-quicksync must render NO /dev/dri hostPath volume and no "
|
||||||
|
"/dev/dri mount (the hostPath mechanism was measured not to work: device-cgroup "
|
||||||
|
"EPERM; the device plugin's DeviceSpec Permissions: rw is what grants access)",
|
||||||
)
|
)
|
||||||
# The device mount is the WHOLE feature: no pod securityContext is invented. The image's
|
# The resource is the WHOLE feature: no pod securityContext is invented. The image's own
|
||||||
# own cont-init hook adds the app user to the device's group inside the container, and
|
# cont-init hook adds the app user to the (plugin-injected) device's group inside the
|
||||||
# s6-setuidgid discards any Kubernetes-supplied supplementalGroups anyway (see the
|
# container, and s6-setuidgid discards any Kubernetes-supplied supplementalGroups anyway
|
||||||
# emission site). Asserting the absence is what stops someone adding an inert one back.
|
# (see the emission site). Asserting the absence is what stops someone adding an inert
|
||||||
|
# one back.
|
||||||
check(
|
check(
|
||||||
"securityContext" not in hw_pod,
|
"securityContext" not in hw_pod,
|
||||||
"hardware.gpu must NOT invent a pod securityContext (supplementalGroups is inert "
|
"hardware.gpu must NOT invent a pod securityContext (supplementalGroups is inert "
|
||||||
@@ -2618,10 +3065,12 @@ def _selftest() -> int:
|
|||||||
)
|
)
|
||||||
# Nothing privileged, and no node pinning: Kyverno restrict-host-namespaces (Enforce)
|
# Nothing privileged, and no node pinning: Kyverno restrict-host-namespaces (Enforce)
|
||||||
# rejects `privileged: true` in an app namespace, and placement is a separate concern
|
# rejects `privileged: true` in an app namespace, and placement is a separate concern
|
||||||
# with its own justification -- this field is about the DEVICE only.
|
# with its own justification -- this field is about the DEVICE only. (The extended
|
||||||
|
# resource now constrains placement to nodes the plugin advertises, which is the
|
||||||
|
# scheduler's own doing, not a nodeSelector this renderer writes.)
|
||||||
check(
|
check(
|
||||||
"privileged" not in hw_pod.get("securityContext", {})
|
"privileged" not in hw_pod.get("securityContext", {})
|
||||||
and "securityContext" not in hw_pod["containers"][0]
|
and "securityContext" not in hw_app
|
||||||
and "nodeSelector" not in hw_pod,
|
and "nodeSelector" not in hw_pod,
|
||||||
"hardware.gpu must emit no privileged securityContext and no nodeSelector",
|
"hardware.gpu must emit no privileged securityContext and no nodeSelector",
|
||||||
)
|
)
|
||||||
@@ -2634,8 +3083,10 @@ def _selftest() -> int:
|
|||||||
check(
|
check(
|
||||||
"volumes" not in nohw_pod
|
"volumes" not in nohw_pod
|
||||||
and "volumeMounts" not in nohw_pod["containers"][0]
|
and "volumeMounts" not in nohw_pod["containers"][0]
|
||||||
|
and "resources" not in nohw_pod["containers"][0]
|
||||||
and "securityContext" not in nohw_pod,
|
and "securityContext" not in nohw_pod,
|
||||||
"an SD without `hardware` must gain no dri volume, no mount and no securityContext",
|
"an SD without `hardware` must gain no resources block, no volume, no mount and no "
|
||||||
|
"securityContext",
|
||||||
)
|
)
|
||||||
# An SD that declares BOTH keeps its own podSecurityContext exactly as written -- the
|
# An SD that declares BOTH keeps its own podSecurityContext exactly as written -- the
|
||||||
# hardware field neither adds to it nor rewrites it.
|
# hardware field neither adds to it nor rewrites it.
|
||||||
@@ -2652,9 +3103,35 @@ def _selftest() -> int:
|
|||||||
if d[1]["kind"] == "Deployment"
|
if d[1]["kind"] == "Deployment"
|
||||||
)["spec"]["template"]["spec"]
|
)["spec"]["template"]["spec"]
|
||||||
check(
|
check(
|
||||||
hwm_pod["securityContext"] == {"fsGroup": 1001, "supplementalGroups": [44]},
|
hwm_pod["securityContext"] == {"fsGroup": 1001, "supplementalGroups": [44]}
|
||||||
|
and hwm_pod["containers"][0].get("resources")
|
||||||
|
== {"limits": {"gpu.intel.com/i915": 1}},
|
||||||
"hardware.gpu must leave the SD's own podSecurityContext untouched",
|
"hardware.gpu must leave the SD's own podSecurityContext untouched",
|
||||||
)
|
)
|
||||||
|
# The resource goes on the APP container only. A forward-auth app renders an oauth2-proxy
|
||||||
|
# sidecar, which does no transcoding and must not consume one of the node's i915 slots.
|
||||||
|
hws_containers = next(
|
||||||
|
d[1]
|
||||||
|
for d in render(
|
||||||
|
{
|
||||||
|
"name": "hws",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"hardware": {"gpu": "intel-quicksync"},
|
||||||
|
"ingressRoutes": [{"host": "hws.home.keithjackson.dev", "port": 8080}],
|
||||||
|
"auth": {"mode": "forward-auth", "groups": ["media-users"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if d[1]["kind"] == "Deployment"
|
||||||
|
)["spec"]["template"]["spec"]["containers"]
|
||||||
|
check(
|
||||||
|
len(hws_containers) == 2
|
||||||
|
and hws_containers[0].get("resources")
|
||||||
|
== {"limits": {"gpu.intel.com/i915": 1}}
|
||||||
|
and "gpu.intel.com/i915"
|
||||||
|
not in hws_containers[1].get("resources", {}).get("limits", {}),
|
||||||
|
"hardware.gpu must put the i915 limit on the APP container only, never on the "
|
||||||
|
"oauth2-proxy sidecar",
|
||||||
|
)
|
||||||
|
|
||||||
# 6c) forward-auth (Option B): an exposed forward-auth app gets an oauth2-proxy sidecar that
|
# 6c) forward-auth (Option B): an exposed forward-auth app gets an oauth2-proxy sidecar that
|
||||||
# fronts the Gateway path (fail-closed), while oidc/native/none apps are unchanged.
|
# fronts the Gateway path (fail-closed), while oidc/native/none apps are unchanged.
|
||||||
@@ -2666,15 +3143,27 @@ def _selftest() -> int:
|
|||||||
"auth": {"mode": "forward-auth", "groups": ["media-users"]},
|
"auth": {"mode": "forward-auth", "groups": ["media-users"]},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
fa_dep = next(d[1] for d in fa_docs if d[1]["kind"] == "Deployment")
|
fa_dep = next((d[1] for d in fa_docs if d[1]["kind"] == "Deployment"), None)
|
||||||
fa_containers = fa_dep["spec"]["template"]["spec"]["containers"]
|
check(fa_dep is not None,
|
||||||
|
"a forward-auth app rendered NO Deployment, so there is no pod to gate at all")
|
||||||
|
fa_containers = (((fa_dep or {}).get("spec") or {}).get("template") or {}) \
|
||||||
|
.get("spec", {}).get("containers") or []
|
||||||
check(
|
check(
|
||||||
len(fa_containers) == 2
|
len(fa_containers) == 2
|
||||||
and fa_containers[0]["name"] == "fa"
|
and fa_containers[0]["name"] == "fa"
|
||||||
and fa_containers[1]["name"] == "oauth2-proxy",
|
and fa_containers[1]["name"] == "oauth2-proxy",
|
||||||
"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND",
|
f"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND; "
|
||||||
|
f"rendered {[c.get('name') for c in fa_containers]!r}",
|
||||||
)
|
)
|
||||||
side = fa_containers[1]
|
# EVERY assertion below reads `side`, so an absent sidecar must NOT be allowed to raise here.
|
||||||
|
# The whole point of the None-tolerant lookups in this block is that a missing sidecar (or a
|
||||||
|
# missing flag on it) reports as NAMED ASSERTIONS rather than as a traceback: an exception
|
||||||
|
# escaping _selftest() exits non-zero with the right verdict and ZERO diagnostics, and it
|
||||||
|
# hides every other assertion the same mutation broke. Measured by Desmond Oyelaran while
|
||||||
|
# wiring this selftest into the render-golden stage: deleting the --cookie-name emission gave
|
||||||
|
# rc 1 via an uncaught StopIteration and printed not one `SELFTEST FAIL:` line. Provoked
|
||||||
|
# again here with the sidecar removed entirely, which raised IndexError at this line.
|
||||||
|
side = fa_containers[1] if len(fa_containers) > 1 else {"args": [], "env": [], "ports": []}
|
||||||
check(
|
check(
|
||||||
"--upstream=http://127.0.0.1:9117" in side["args"],
|
"--upstream=http://127.0.0.1:9117" in side["args"],
|
||||||
"sidecar upstream must be the co-located app UI over http on 127.0.0.1:<uiport>",
|
"sidecar upstream must be the co-located app UI over http on 127.0.0.1:<uiport>",
|
||||||
@@ -2743,12 +3232,101 @@ def _selftest() -> int:
|
|||||||
"sidecar must declare BOTH the http and metrics container ports — an undeclared port "
|
"sidecar must declare BOTH the http and metrics container ports — an undeclared port "
|
||||||
"cannot be named by a Service port or a PodMonitor, so the flag alone is inert",
|
"cannot be named by a Service port or a PodMonitor, so the flag alone is inert",
|
||||||
)
|
)
|
||||||
|
# API ROUTES (schema 1.9.0). Three properties, and the FIRST is the one that keeps the field
|
||||||
|
# honest: an app that declares nothing must render NO --api-route. `fa` above declares none.
|
||||||
|
check(
|
||||||
|
not any(a.startswith("--api-route") for a in side["args"]),
|
||||||
|
"a forward-auth app that declares no auth.apiRoutes must render NO --api-route. A "
|
||||||
|
"default or a renderer constant would answer PiHole's page loads with 401 — PiHole "
|
||||||
|
"serves no /api/ at all — so the service would look permanently broken to a "
|
||||||
|
"logged-out operator instead of signing them in",
|
||||||
|
)
|
||||||
|
# Same `next(..., None)` discipline as _cookie_name() below, for the same reason: if the
|
||||||
|
# sidecar stops being rendered at all, this must report as a named assertion rather than as a
|
||||||
|
# StopIteration that swallows every other SELFTEST FAIL line in the run.
|
||||||
|
_faapi_dep = next(
|
||||||
|
(
|
||||||
|
d[1]
|
||||||
|
for d in render(
|
||||||
|
{
|
||||||
|
"name": "faapi",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"ingressRoutes": [{"host": "faapi.home.keithjackson.dev", "port": 9119}],
|
||||||
|
"auth": {
|
||||||
|
"mode": "forward-auth",
|
||||||
|
"groups": ["media-users"],
|
||||||
|
"apiRoutes": ["^/api/", "^/signalr/"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if d[1]["kind"] == "Deployment"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
fa_api_side = next(
|
||||||
|
(
|
||||||
|
c
|
||||||
|
for c in ((_faapi_dep or {}).get("spec", {}).get("template", {})
|
||||||
|
.get("spec", {}).get("containers") or [])
|
||||||
|
if c.get("name") == "oauth2-proxy"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
fa_api_side is not None,
|
||||||
|
"a forward-auth app declaring auth.apiRoutes rendered NO oauth2-proxy sidecar, so the "
|
||||||
|
"routes have nowhere to land and the app is not gated at all (R-IDP-02)",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
fa_api_side is not None
|
||||||
|
and [a for a in fa_api_side["args"] if a.startswith("--api-route")]
|
||||||
|
== ["--api-route=^/api/", "--api-route=^/signalr/"],
|
||||||
|
"a declared auth.apiRoutes must render as one repeatable --api-route per entry, in "
|
||||||
|
"declared order, and NOTHING else. Note the flag is SINGULAR: --api-routes does not "
|
||||||
|
"exist in v7.8.1 and the proxy refuses to start with it",
|
||||||
|
)
|
||||||
|
# BREADTH. The renderer must REFUSE a regex that would match a page load, not merely
|
||||||
|
# discourage one, because the schema stage can be skipped and render-golden cannot.
|
||||||
|
for _bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
|
||||||
|
try:
|
||||||
|
render(
|
||||||
|
{
|
||||||
|
"name": "fabad",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"ingressRoutes": [{"host": "fabad.home.keithjackson.dev", "port": 9120}],
|
||||||
|
"auth": {
|
||||||
|
"mode": "forward-auth",
|
||||||
|
"groups": ["media-users"],
|
||||||
|
"apiRoutes": [_bad],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_refused = False
|
||||||
|
except SystemExit:
|
||||||
|
_refused = True
|
||||||
|
check(
|
||||||
|
_refused,
|
||||||
|
f"the renderer ACCEPTED auth.apiRoutes entry {_bad!r}. A too-broad --api-route "
|
||||||
|
f"answers a plain page load with 401, so a logged-out operator sees a bare error "
|
||||||
|
f"page and is never offered a sign-in — the service looks permanently broken "
|
||||||
|
f"rather than merely unauthenticated, which is a worse failure than the 302 bug "
|
||||||
|
f"this flag exists to fix",
|
||||||
|
)
|
||||||
|
for _good in ("^/api/", "^/signalr/", "^/api/v3/.*", "^/api/v2.0/server/update$"):
|
||||||
|
check(
|
||||||
|
bool(OAUTH2_API_ROUTE_PATTERN.match(_good)),
|
||||||
|
f"the breadth ratchet rejected the legitimate route {_good!r}. A ratchet that "
|
||||||
|
f"blocks real API prefixes is a ratchet someone eventually deletes",
|
||||||
|
)
|
||||||
|
|
||||||
# Two forward-auth apps must not be able to collide. Renders a SECOND one and compares, so a
|
# Two forward-auth apps must not be able to collide. Renders a SECOND one and compares, so a
|
||||||
# future refactor to a constant (or to a per-service table with a copy-paste slip) fails here
|
# future refactor to a constant (or to a per-service table with a copy-paste slip) fails here
|
||||||
# rather than silently in a browser six weeks later.
|
# rather than silently in a browser six weeks later.
|
||||||
fa2_side = next(
|
# Same None-tolerant shape as the `faapi` fixture above and for the same reason: a missing
|
||||||
c
|
# sidecar must surface as a named assertion, never as a traceback that suppresses the
|
||||||
for c in next(
|
# collected SELFTEST FAIL lines.
|
||||||
|
_fa2_dep = next(
|
||||||
|
(
|
||||||
d[1]
|
d[1]
|
||||||
for d in render(
|
for d in render(
|
||||||
{
|
{
|
||||||
@@ -2759,24 +3337,55 @@ def _selftest() -> int:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if d[1]["kind"] == "Deployment"
|
if d[1]["kind"] == "Deployment"
|
||||||
)["spec"]["template"]["spec"]["containers"]
|
),
|
||||||
if c["name"] == "oauth2-proxy"
|
None,
|
||||||
|
)
|
||||||
|
fa2_side = next(
|
||||||
|
(
|
||||||
|
c
|
||||||
|
for c in ((_fa2_dep or {}).get("spec", {}).get("template", {})
|
||||||
|
.get("spec", {}).get("containers") or [])
|
||||||
|
if c.get("name") == "oauth2-proxy"
|
||||||
|
),
|
||||||
|
{"args": []},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# `next(..., None)` — the DEFAULT IS LOAD-BEARING, not defensive style. With a bare
|
||||||
|
# `next(genexpr)` this raises StopIteration the moment a sidecar renders no --cookie-name,
|
||||||
|
# which is precisely the mutation this assertion exists to catch. The traceback then escapes
|
||||||
|
# _selftest() before its collected `SELFTEST FAIL:` lines are printed, so the run exits
|
||||||
|
# non-zero with the RIGHT VERDICT AND NO DIAGNOSTICS — and it masks every other assertion the
|
||||||
|
# same mutation broke. Measured by Desmond Oyelaran while wiring the selftest into the
|
||||||
|
# render-golden stage: deleting the --cookie-name emission gave rc 1 via an uncaught
|
||||||
|
# StopIteration and suppressed all of the messages. A missing flag must report AS AN
|
||||||
|
# ASSERTION, with its name in the output, which is what the None + explicit check below do.
|
||||||
def _cookie_name(container):
|
def _cookie_name(container):
|
||||||
return next(a for a in container["args"] if a.startswith("--cookie-name="))
|
return next((a for a in container["args"] if a.startswith("--cookie-name=")), None)
|
||||||
|
|
||||||
|
_fa_cn, _fa2_cn = _cookie_name(side), _cookie_name(fa2_side)
|
||||||
check(
|
check(
|
||||||
_cookie_name(side) != _cookie_name(fa2_side),
|
_fa_cn is not None and _fa2_cn is not None,
|
||||||
|
f"a forward-auth sidecar rendered NO --cookie-name at all (fa={_fa_cn!r}, "
|
||||||
|
f"fa2={_fa2_cn!r}). Omitting the flag is not neutral: oauth2-proxy then claims its "
|
||||||
|
f"built-in default `_oauth2_proxy`, which every other instance on the platform also "
|
||||||
|
f"claims, so they overwrite and ClearSession-delete each other's sessions",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
_fa_cn != _fa2_cn,
|
||||||
"two forward-auth apps rendered IDENTICAL cookie names — they would overwrite each "
|
"two forward-auth apps rendered IDENTICAL cookie names — they would overwrite each "
|
||||||
"other's session on every sign-in (the 2026-09-29 crafty defect, fleet-wide)",
|
"other's session on every sign-in (the 2026-09-29 crafty defect, fleet-wide)",
|
||||||
)
|
)
|
||||||
|
# Index-safe for the same reason as `side` above: an absent sidecar has no env, and an
|
||||||
|
# IndexError here would exit non-zero with no diagnostics at all.
|
||||||
|
_fa_env = (side.get("env") or [{}])[0]
|
||||||
check(
|
check(
|
||||||
side["env"][0]["valueFrom"]["secretKeyRef"]["name"] == "fa-oauth2-proxy",
|
(((_fa_env.get("valueFrom") or {}).get("secretKeyRef") or {}).get("name")
|
||||||
|
== "fa-oauth2-proxy"),
|
||||||
"sidecar creds must come by name from the per-app <app>-oauth2-proxy Secret (R-IDP-04)",
|
"sidecar creds must come by name from the per-app <app>-oauth2-proxy Secret (R-IDP-04)",
|
||||||
)
|
)
|
||||||
fa_svc = next(d[1] for d in fa_docs if d[1]["kind"] == "Service")
|
fa_svc = next((d[1] for d in fa_docs if d[1]["kind"] == "Service"), None)
|
||||||
fa_svc_ports = {p["port"] for p in fa_svc["spec"]["ports"]}
|
check(fa_svc is not None, "a forward-auth app must render a Service for the Gateway to reach")
|
||||||
|
fa_svc_ports = {p["port"] for p in (((fa_svc or {}).get("spec") or {}).get("ports") or [])}
|
||||||
check(
|
check(
|
||||||
fa_svc_ports == {9117, 4180},
|
fa_svc_ports == {9117, 4180},
|
||||||
"forward-auth Service must expose the app port (east-west) AND :4180 (gateway->sidecar)",
|
"forward-auth Service must expose the app port (east-west) AND :4180 (gateway->sidecar)",
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ dependencies:
|
|||||||
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
||||||
port: 9117
|
port: 9117
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
- to: plex # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
|
||||||
|
port: 32400
|
||||||
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: sonarr.${BASE_DOMAIN}
|
- host: sonarr.${BASE_DOMAIN}
|
||||||
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||||
@@ -112,6 +115,14 @@ auth:
|
|||||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||||
- platform-admins # operators of these services must not be locked out of them
|
- platform-admins # operators of these services must not be locked out of them
|
||||||
|
# NO `apiRoutes` HERE, AND NOT BECAUSE IT NEEDS NONE — because nothing measures that it does.
|
||||||
|
# Sonarr is nearly the same codebase as Radarr, which is exactly what makes `^/api/` +
|
||||||
|
# `^/signalr/` a tempting guess and still a guess. MEASURED 2026-10-01 on the live pod
|
||||||
|
# (sonarr-ddf485775-8ckwt, 0 restarts, 23h): its oauth2-proxy log contains 13,643 lines and
|
||||||
|
# ZERO unauthenticated requests — no /api/, no /signalr/, nothing but health probes. Nobody
|
||||||
|
# has opened this UI since the cookie fix rolled, so the measurement is empty rather than
|
||||||
|
# negative: it is evidence of no TRAFFIC, not evidence of no need. Add the routes when someone
|
||||||
|
# reproduces the 302-on-XHR here, with the numbers.
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=4.0.0 <5.0.0" # stay on the Sonarr v4 stable line
|
range: ">=4.0.0 <5.0.0" # stay on the Sonarr v4 stable line
|
||||||
|
|||||||
Executable
+194
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ci/approval-override.py — select the admin-override on a PR, if there is a valid one.
|
||||||
|
|
||||||
|
This script decides NOTHING about authority. It parses a PR's comments and reviews and returns
|
||||||
|
the single override *candidate* that ci/approval-policy.sh should then try to authorise against
|
||||||
|
the live Gitea permission API. Keeping the two apart is the point:
|
||||||
|
|
||||||
|
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
|
||||||
|
* authorisation needs the API and must stay in the stage that fails closed.
|
||||||
|
|
||||||
|
A candidate is a comment OR a review whose body contains, AT THE START OF A LINE, the directive
|
||||||
|
followed by a non-empty reason:
|
||||||
|
|
||||||
|
/approve-override the gate cannot gate itself and I am the only human reviewer
|
||||||
|
|
||||||
|
TWO SOURCES, AND WHY THAT IS NOT BELT-AND-BRACES. Measured 2026-10-02 on PR #38: the override was
|
||||||
|
posted as an ordinary PR comment, was well-formed, was newer than the head commit — and the stage
|
||||||
|
still blocked, because CI could not READ it. `SETTINGS_API_TOKEN` is minted with scopes
|
||||||
|
`[read:repository, read:admin]`, and Gitea puts /issues/{n}/comments behind the *issue* scope, so
|
||||||
|
the request came back as an error OBJECT rather than an array. `/pulls/{n}/reviews` is under
|
||||||
|
read:repository and was readable in the same run. So:
|
||||||
|
|
||||||
|
* a REVIEW body works with the token CI has today (leave a review of type "Comment");
|
||||||
|
* a COMMENT body works once the token carries read:issue (see infra/gitea/set-ci-token.sh).
|
||||||
|
|
||||||
|
Accepting both means the hatch is not hostage to one API scope. A source that cannot be read
|
||||||
|
contributes no candidates and is reported on stderr; it can only cause a false BLOCK, never a
|
||||||
|
false pass, which is why degrading it is safe and blocking on it was not necessary.
|
||||||
|
|
||||||
|
WHY EACH RULE IS HERE
|
||||||
|
|
||||||
|
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
|
||||||
|
("you could just /approve-override this") re-arms it. Lines beginning with '>' are DROPPED
|
||||||
|
rather than unquoted, for exactly that reason.
|
||||||
|
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why, which
|
||||||
|
is the difference between an audit trail and a rubber stamp.
|
||||||
|
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover commit
|
||||||
|
B. Any candidate created at or before the head commit's timestamp is dropped — the same
|
||||||
|
semantics Gitea's own `dismiss_stale_approvals` applies to approvals. Push new commits and
|
||||||
|
you must re-override.
|
||||||
|
* A PENDING REVIEW IS NOT SUBMITTED. It is a private draft visible only to its author, so it is
|
||||||
|
not an act of anything and is dropped.
|
||||||
|
* THE LATEST CANDIDATE WINS, across both sources, so a later override supersedes an earlier one.
|
||||||
|
|
||||||
|
Output: one TSV line (login, timestamp, source, reason) on stdout, or nothing at all when there is
|
||||||
|
no valid candidate. Never exits non-zero for "no override" — absence is a normal answer and the
|
||||||
|
caller must not be able to confuse it with a parse crash.
|
||||||
|
|
||||||
|
Input, all via env so nothing lands in `ps`:
|
||||||
|
COMMENTS_JSON the Gitea /issues/{n}/comments array (or an error object; or absent)
|
||||||
|
REVIEWS_JSON the Gitea /pulls/{n}/reviews array (or an error object; or absent)
|
||||||
|
HEAD_TIME ISO-8601 timestamp of the PR head commit
|
||||||
|
DIRECTIVE the override directive, e.g. /approve-override
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ts(value):
|
||||||
|
"""Gitea emits RFC3339. Return an aware datetime, or None if it is unparseable.
|
||||||
|
|
||||||
|
Unparseable is NOT 'treat as fresh': the caller drops such a candidate, because a
|
||||||
|
timestamp we cannot read is a staleness check we cannot perform.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
# Python < 3.11 cannot read a trailing 'Z' in fromisoformat.
|
||||||
|
if text.endswith(("Z", "z")):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reason(body, directive):
|
||||||
|
"""Return the reason from the first line that STARTS with the directive, else None."""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^[ \t]*" + re.escape(directive) + r"(?=[ \t:]|$)[ \t:]*(.*)$"
|
||||||
|
)
|
||||||
|
for raw in (body or "").splitlines():
|
||||||
|
# A quoted line is somebody repeating an override, not casting one.
|
||||||
|
if raw.lstrip().startswith(">"):
|
||||||
|
continue
|
||||||
|
match = pattern.match(raw)
|
||||||
|
if match:
|
||||||
|
reason = match.group(1).strip()
|
||||||
|
return reason or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_source(env_name, label):
|
||||||
|
"""Return a list of items for one source, or [] with a stderr note if it is unusable."""
|
||||||
|
raw = os.environ.get(env_name)
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
print(
|
||||||
|
"approval-override: %s is not valid JSON — ignoring that source" % label,
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
# An error object, typically {"message": "token does not have ... scope"}. Say which,
|
||||||
|
# because "no override found" and "could not read the overrides" look identical from
|
||||||
|
# the outside and the first wasted a real debugging session.
|
||||||
|
detail = ""
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
detail = str(parsed.get("message") or parsed.get("errors") or "")[:200]
|
||||||
|
print(
|
||||||
|
"approval-override: %s is not a list (Gitea returned %s%s) — ignoring that source"
|
||||||
|
% (label, type(parsed).__name__, ": " + detail if detail else ""),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def candidates_from(items, source, directive, head_time):
|
||||||
|
"""Yield (when, login, reason, raw_when, source) for every valid override in one source."""
|
||||||
|
found = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
login = ((item.get("user") or {}).get("login") or "").strip()
|
||||||
|
reason = extract_reason(item.get("body"), directive)
|
||||||
|
if not login or not reason:
|
||||||
|
continue
|
||||||
|
# A PENDING review is an unsubmitted private draft — not an act of anything.
|
||||||
|
state = str(item.get("state") or "").upper()
|
||||||
|
if source == "review" and state == "PENDING":
|
||||||
|
print(
|
||||||
|
"approval-override: dropping PENDING (unsubmitted) review by '%s'" % login,
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raw_when = (
|
||||||
|
item.get("submitted_at") or item.get("created_at") or item.get("updated_at") or ""
|
||||||
|
)
|
||||||
|
when = parse_ts(raw_when)
|
||||||
|
if when is None:
|
||||||
|
print(
|
||||||
|
"approval-override: dropping %s override by '%s' — unreadable timestamp %r"
|
||||||
|
% (source, login, raw_when),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# An override must post-date the commit it covers.
|
||||||
|
if head_time is not None and when <= head_time:
|
||||||
|
print(
|
||||||
|
"approval-override: dropping STALE %s override by '%s' (%s) — not newer than "
|
||||||
|
"head commit (%s). Re-post it to override the current commit."
|
||||||
|
% (source, login, when.isoformat(), head_time.isoformat()),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
found.append((when, login, reason, raw_when, source))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
|
||||||
|
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
|
||||||
|
|
||||||
|
found = []
|
||||||
|
found += candidates_from(
|
||||||
|
load_source("COMMENTS_JSON", "COMMENTS_JSON"), "comment", directive, head_time
|
||||||
|
)
|
||||||
|
found += candidates_from(
|
||||||
|
load_source("REVIEWS_JSON", "REVIEWS_JSON"), "review", directive, head_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Latest wins, across both sources: a later override supersedes an earlier one.
|
||||||
|
when, login, reason, raw_when, source = max(found, key=lambda c: c[0])
|
||||||
|
# Keep the reason on one line — this becomes a TSV field.
|
||||||
|
reason = " ".join(reason.split())
|
||||||
|
print("%s\t%s\t%s\t%s" % (login, raw_when, source, reason))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+161
-4
@@ -25,10 +25,45 @@
|
|||||||
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
||||||
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
||||||
#
|
#
|
||||||
|
# THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears.
|
||||||
|
#
|
||||||
|
# The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a
|
||||||
|
# pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an
|
||||||
|
# APPROVED review from someone who is not its author" is a condition the sole administrator can
|
||||||
|
# never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR
|
||||||
|
# that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked.
|
||||||
|
#
|
||||||
|
# So an administrator may waive the review by COMMENTING on the PR:
|
||||||
|
#
|
||||||
|
# /approve-override <reason>
|
||||||
|
#
|
||||||
|
# Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must
|
||||||
|
# hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the
|
||||||
|
# gate runs, so an old comment from a since-demoted account is worth nothing. The directive must
|
||||||
|
# start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override
|
||||||
|
# earned at commit A does not cover commit B. Choosing the candidate comment is the job of the
|
||||||
|
# pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage,
|
||||||
|
# which fails closed.
|
||||||
|
#
|
||||||
|
# WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by
|
||||||
|
# the gate" stops being structural and becomes advisory. That is the unavoidable consequence of
|
||||||
|
# there being one human; the alternative is a repository its owner cannot change. What is bought
|
||||||
|
# back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated
|
||||||
|
# reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an
|
||||||
|
# admin?" would have been less code and is exactly the fail-open shape this repo keeps finding.
|
||||||
|
#
|
||||||
|
# AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of
|
||||||
|
# human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The
|
||||||
|
# AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the
|
||||||
|
# sensitive-path review on its own PR by posting the directive — and agents author most PRs in
|
||||||
|
# this repo, so that is the common case, not an edge case. To narrow it without touching this
|
||||||
|
# logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below
|
||||||
|
# already requires membership whenever that key is present.
|
||||||
|
#
|
||||||
# EXIT CODES
|
# EXIT CODES
|
||||||
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
|
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and
|
||||||
# non-author APPROVED review is present
|
# either a non-author APPROVED review or a valid admin override is present
|
||||||
# 1 sensitive paths touched without such a review, OR the policy could not be evaluated
|
# 1 sensitive paths touched with neither, OR the policy could not be evaluated
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||||
@@ -65,6 +100,17 @@ api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
|||||||
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
||||||
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
||||||
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
||||||
|
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
|
||||||
|
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
|
||||||
|
# Verified against this Gitea (1.24.3) rather than assumed.
|
||||||
|
#
|
||||||
|
# This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits
|
||||||
|
# behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted
|
||||||
|
# `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that
|
||||||
|
# turned "CI cannot read your override" into a message that said "you did not post one", and cost a
|
||||||
|
# real debugging session on PR #38. It is handled explicitly, and loudly, further down: an
|
||||||
|
# unreadable source can only cause a false BLOCK, never a false pass.
|
||||||
|
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
|
||||||
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||||
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||||
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||||
@@ -107,11 +153,122 @@ if [ -n "$APPROVER" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
# No non-author approval exists. Before refusing, look for an administrator's explicit
|
||||||
|
# override. See the header for why this hatch exists and what it costs.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")"
|
||||||
|
DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")"
|
||||||
|
HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")"
|
||||||
|
|
||||||
|
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
||||||
|
# The head commit's timestamp anchors staleness. Read it from GITEA, not from the local
|
||||||
|
# tree: CI checks out a merge commit, whose date is not the head commit's date.
|
||||||
|
HEAD_TIME=""
|
||||||
|
if [ -n "$HEAD_SHA" ]; then
|
||||||
|
HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)"
|
||||||
|
HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$HEAD_TIME" ]; then
|
||||||
|
err "could not read the head commit's timestamp for PR #${PR} — BLOCKING."
|
||||||
|
err " Without it an override cannot be aged, and an unaged override is one that silently"
|
||||||
|
err " covers commits nobody waived review on."
|
||||||
|
summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Is the comments source actually readable? Say so plainly if not — a scope problem and "you
|
||||||
|
# never posted an override" are indistinguishable from the outside, and conflating them is what
|
||||||
|
# made PR #38 look like a code bug.
|
||||||
|
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then
|
||||||
|
SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)"
|
||||||
|
warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}."
|
||||||
|
warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted"
|
||||||
|
warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an"
|
||||||
|
warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type"
|
||||||
|
warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository."
|
||||||
|
summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override"
|
||||||
|
COMMENTS_JSON='[]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pure, offline, unit-tested selection: which comment or review, if any, is a live override?
|
||||||
|
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
|
||||||
|
python3 "${CI_DIR}/approval-override.py")" || {
|
||||||
|
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
|
||||||
|
summary "- approval-policy: **BLOCKED — override parser failed**"
|
||||||
|
exit 1; }
|
||||||
|
|
||||||
|
if [ -n "$OVERRIDE_TSV" ]; then
|
||||||
|
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
|
||||||
|
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
|
||||||
|
OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")"
|
||||||
|
OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")"
|
||||||
|
|
||||||
|
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
|
||||||
|
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
|
||||||
|
OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)"
|
||||||
|
if [ -z "$OV_PERM" ]; then
|
||||||
|
err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be"
|
||||||
|
err " read — BLOCKING. An override whose authority is unverified is not an override."
|
||||||
|
summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Authorised when the LIVE permission is one of the declared ones and — only if an
|
||||||
|
# `override_admins` allowlist exists at all — the account is on it. No allowlist means
|
||||||
|
# permission alone, which is the operator's standing decision; adding the key narrows
|
||||||
|
# this with no change to any code.
|
||||||
|
AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c '
|
||||||
|
import json, os
|
||||||
|
d = json.load(open(os.environ["DECL"])).get("override", {})
|
||||||
|
perms = d.get("authorized_permissions") or ["admin", "owner"]
|
||||||
|
allow = d.get("override_admins")
|
||||||
|
ok_perm = os.environ["OV_PERM"] in perms
|
||||||
|
ok_list = True if allow is None else os.environ["OV_USER"] in allow
|
||||||
|
print("yes" if (ok_perm and ok_list) else "no")
|
||||||
|
')"
|
||||||
|
|
||||||
|
if [ "$AUTHORISED" = "yes" ]; then
|
||||||
|
if [ "$OV_USER" = "$AUTHOR" ]; then
|
||||||
|
warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the"
|
||||||
|
warn " non-author review requirement on their own change. Permitted (permission"
|
||||||
|
warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free."
|
||||||
|
else
|
||||||
|
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
|
||||||
|
fi
|
||||||
|
warn " via: PR ${OV_SRC}"
|
||||||
|
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
|
||||||
|
warn " reason: ${OV_WHY}"
|
||||||
|
warn " review waived on: $(printf '%s ' $SENSITIVE)"
|
||||||
|
ok "sensitive paths cleared by administrator override — this stage does not block."
|
||||||
|
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
|
||||||
|
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})"
|
||||||
|
summary " - reason: ${OV_WHY}"
|
||||||
|
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is"
|
||||||
|
err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json."
|
||||||
|
summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
||||||
err " review from anyone other than its author ('${AUTHOR}')."
|
err " review from anyone other than its author ('${AUTHOR}')."
|
||||||
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
||||||
err " honestly approve for itself. Get a human review, or move the change out of:"
|
err " honestly approve for itself. Get a human review, or move the change out of:"
|
||||||
printf ' %s\n' $SENSITIVE >&2
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
||||||
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
|
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
||||||
|
err " Or, if you hold admin/owner on this repository, post this on the PR:"
|
||||||
|
err " ${DIRECTIVE} <reason>"
|
||||||
|
err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read"
|
||||||
|
err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)."
|
||||||
|
err " The reason is mandatory, the directive must START a line, and it must be NEWER than"
|
||||||
|
err " head commit ${HEAD_SHA:0:8} — push again and you must override again."
|
||||||
|
fi
|
||||||
|
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
|
||||||
exit 1
|
exit 1
|
||||||
+54
-1
@@ -1,5 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ci/render-golden.sh — Stage: render==golden reproducibility gate.
|
# ci/render-golden.sh — Stage: the renderer is trustworthy.
|
||||||
|
# (1) catalog/render.py passes its OWN output-contract self-test, and
|
||||||
|
# (2) every committed apps/ golden reproduces byte-for-byte from the canonical render.
|
||||||
|
# Two independent assertions about one subject, catalog/render.py. (1) was added
|
||||||
|
# 2026-10-01 because nothing in ci/ or .gitea/ had ever invoked `--selftest`; see the
|
||||||
|
# block above the invocation for why it lives here and not in a conformance check.
|
||||||
#
|
#
|
||||||
# WHY: the committed apps/ tree is a set of GOLDENS produced by ONE canonical
|
# WHY: the committed apps/ tree is a set of GOLDENS produced by ONE canonical
|
||||||
# invocation of catalog/render.py. If a golden is hand-edited, or a catalog
|
# invocation of catalog/render.py. If a golden is hand-edited, or a catalog
|
||||||
@@ -54,6 +59,54 @@ if [ ! -f "$RENDER_PY" ]; then
|
|||||||
summary "- render-golden: **SKIPPED** (renderer missing)"
|
summary "- render-golden: **SKIPPED** (renderer missing)"
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
# --- The renderer's OWN self-test, before anything is compared ---------------
|
||||||
|
# `catalog/render.py --selftest` is ~106 executed assertions on the renderer's output
|
||||||
|
# contract (default-deny covers both directions, the Kyverno namespace hook label, a valid
|
||||||
|
# auth-mode, the oauth2-proxy sidecar's cookie-name/-domain/-refresh flags, the
|
||||||
|
# placeholder-substitution guard, the observability refusals...). Until now NOTHING in ci/
|
||||||
|
# or .gitea/ invoked it — measured by grep on 2026-09-30 and recorded as the top follow-up
|
||||||
|
# of decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md — so every one of those
|
||||||
|
# assertions was dev-time only and a merge gate could not tell a sound renderer from a
|
||||||
|
# gutted one.
|
||||||
|
#
|
||||||
|
# WHY HERE, AND NOT IN A CONFORMANCE CHECK. The lead was
|
||||||
|
# conformance/tests/l0-static/auditability-proofs-producible/check.sh, which drives
|
||||||
|
# `conformance/lib/{record_run,status}.py --selftest` "because nothing else drives them".
|
||||||
|
# That loop is NOT the right hook: that check serves R-SEC-03 and its subject is the
|
||||||
|
# EVIDENCE CHAIN (can a forged or stale run become a proof?). The renderer is not part of
|
||||||
|
# that chain, and hanging it there would make the check's name and its requirement mapping
|
||||||
|
# a lie. This stage's subject already IS catalog/render.py — it exists to assert that the
|
||||||
|
# renderer's output is trustworthy — so the renderer's own contract test belongs in it.
|
||||||
|
#
|
||||||
|
# It runs BEFORE the catalog/empty-catalog exit-3 below on purpose: the self-test is
|
||||||
|
# deterministic and catalog-independent (it renders synthetic ServiceDefinitions in
|
||||||
|
# memory), so an empty catalog must not be able to skip it.
|
||||||
|
#
|
||||||
|
# render-golden and the self-test are INDEPENDENT assertions, which is why both are needed:
|
||||||
|
# render-golden catches golden-vs-renderer drift but is satisfied by ANY renderer whose
|
||||||
|
# output was re-rendered, including one that just lost a contract invariant and had all ten
|
||||||
|
# goldens regenerated in the same commit. The self-test catches exactly that.
|
||||||
|
#
|
||||||
|
# FAILURE IS A HARD FAIL, NOT A SKIP: the renderer is source in this repo, so "could not
|
||||||
|
# run its self-test" has no legitimate environmental cause once python3 and render.py are
|
||||||
|
# both present (both asserted above). ci/gate.sh maps this stage's rc 1 to BLOCKED.
|
||||||
|
log "Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)"
|
||||||
|
selftest_out="$(mktemp)"
|
||||||
|
if "$PYTHON" "$RENDER_PY" --selftest >"$selftest_out" 2>&1; then
|
||||||
|
ok "render.py --selftest passed"
|
||||||
|
summary "- render-selftest: **passed** (\`catalog/render.py --selftest\`)"
|
||||||
|
else
|
||||||
|
err "catalog/render.py --selftest FAILED — the renderer violates its own output contract."
|
||||||
|
err " Every golden under apps/ is produced by this renderer, so the goldens below cannot"
|
||||||
|
err " be trusted even if they reproduce byte-for-byte. Reproduce with:"
|
||||||
|
err " ${PYTHON} catalog/render.py --selftest"
|
||||||
|
sed 's/^/ /' "$selftest_out" >&2 || true
|
||||||
|
summary "- render-selftest: **FAILED** (\`catalog/render.py --selftest\` — renderer contract violated)"
|
||||||
|
rm -f "$selftest_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$selftest_out"
|
||||||
|
|
||||||
if [ ! -d "$CATALOG_DIR" ] || ! compgen -G "${CATALOG_DIR}/*.yaml" >/dev/null 2>&1; then
|
if [ ! -d "$CATALOG_DIR" ] || ! compgen -G "${CATALOG_DIR}/*.yaml" >/dev/null 2>&1; then
|
||||||
skip "no catalog ServiceDefinitions under '${CATALOG_DIR}' — nothing to render."
|
skip "no catalog ServiceDefinitions under '${CATALOG_DIR}' — nothing to render."
|
||||||
summary "- render-golden: **SKIPPED** (empty catalog)"
|
summary "- render-golden: **SKIPPED** (empty catalog)"
|
||||||
|
|||||||
Executable
+232
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/tests/approval-override.test.sh — proves ci/approval-override.py selects the override it
|
||||||
|
# claims to select, and REJECTS the ones it claims to reject.
|
||||||
|
#
|
||||||
|
# This parser decides whether a human review requirement gets waived. Every rule in it exists
|
||||||
|
# because the obvious implementation ("does the body contain the directive?") is wrong in a way
|
||||||
|
# that is invisible until abused: a quoted directive would re-arm an override, a bare directive
|
||||||
|
# would waive review with no stated reason, and an override posted against commit A would go on
|
||||||
|
# covering commit B forever. A guard is worth nothing until somebody has watched it refuse, which
|
||||||
|
# is the same reasoning as ci/tests/decisions-log.test.sh one layer up.
|
||||||
|
#
|
||||||
|
# The parser is pure and offline BY DESIGN — authorisation (the live permission lookup) lives in
|
||||||
|
# ci/approval-policy.sh, which cannot be tested without Gitea. That split is what makes this file
|
||||||
|
# possible, and it is also why a green run here does NOT prove the stage authorises correctly.
|
||||||
|
set -uo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SUT="${CI_DIR}/approval-override.py"
|
||||||
|
|
||||||
|
PASS=0 FAIL=0
|
||||||
|
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
||||||
|
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
HEAD_TIME="2026-10-01T12:00:00Z"
|
||||||
|
AFTER="2026-10-01T12:30:00Z"
|
||||||
|
BEFORE="2026-10-01T11:30:00Z"
|
||||||
|
|
||||||
|
# run <comments-json> -> stdout of the parser (stderr is diagnostic only)
|
||||||
|
run() {
|
||||||
|
COMMENTS_JSON="$1" REVIEWS_JSON="" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||||
|
python3 "$SUT" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_rev <reviews-json> — the same, but through the REVIEWS source.
|
||||||
|
run_rev() {
|
||||||
|
COMMENTS_JSON="" REVIEWS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||||
|
python3 "$SUT" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# comment <login> <created_at> <body>
|
||||||
|
comment() {
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
print(json.dumps([{"user": {"login": sys.argv[1]}, "created_at": sys.argv[2], "body": sys.argv[3]}]))
|
||||||
|
' "$1" "$2" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_none() {
|
||||||
|
local label="$1" out
|
||||||
|
out="$(run "$2")"
|
||||||
|
if [ -z "$out" ]; then ok "$label"; else bad "$label (got: ${out})"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_user() {
|
||||||
|
local label="$1" json="$2" want="$3" out got
|
||||||
|
out="$(run "$json")"
|
||||||
|
got="$(cut -f1 <<<"$out")"
|
||||||
|
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted ${want}, got '${got}')"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_reason() {
|
||||||
|
local label="$1" json="$2" want="$3" out got
|
||||||
|
out="$(run "$json")"
|
||||||
|
got="$(cut -f4- <<<"$out")"
|
||||||
|
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# check <label> <actual> <wanted>
|
||||||
|
check() {
|
||||||
|
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (wanted '${3}', got '${2}')"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# review <login> <submitted_at> <state> <body>
|
||||||
|
review() {
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
print(json.dumps([{"user": {"login": sys.argv[1]}, "submitted_at": sys.argv[2],
|
||||||
|
"state": sys.argv[3], "body": sys.argv[4]}]))
|
||||||
|
' "$1" "$2" "$3" "$4"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== ci/approval-override.py"
|
||||||
|
|
||||||
|
# --- the happy path -------------------------------------------------------------------
|
||||||
|
expect_user "a directive with a reason, posted after the head commit, is selected" \
|
||||||
|
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" jakman85
|
||||||
|
|
||||||
|
expect_reason "the reason is returned verbatim" \
|
||||||
|
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" \
|
||||||
|
'sole admin, gate is green'
|
||||||
|
|
||||||
|
# --- absence is a normal answer, not an error -----------------------------------------
|
||||||
|
expect_none "no comments at all" '[]'
|
||||||
|
expect_none "an ordinary comment" "$(comment jakman85 "$AFTER" 'looks good to me')"
|
||||||
|
|
||||||
|
# --- a reason is MANDATORY ------------------------------------------------------------
|
||||||
|
expect_none "a bare directive (no reason) is refused" \
|
||||||
|
"$(comment jakman85 "$AFTER" '/approve-override')"
|
||||||
|
expect_none "a directive followed by only whitespace is refused" \
|
||||||
|
"$(comment jakman85 "$AFTER" '/approve-override ')"
|
||||||
|
|
||||||
|
# --- the directive must START a line --------------------------------------------------
|
||||||
|
expect_none "the directive mid-sentence does not count" \
|
||||||
|
"$(comment jakman85 "$AFTER" 'you could just /approve-override this one')"
|
||||||
|
expect_none "a QUOTED directive does not re-arm an override" \
|
||||||
|
"$(comment jakman85 "$AFTER" '> /approve-override quoting someone else')"
|
||||||
|
expect_user "leading whitespace before the directive is fine" \
|
||||||
|
"$(comment jakman85 "$AFTER" ' /approve-override indented but mine')" jakman85
|
||||||
|
expect_user "the directive on a later line of a longer comment counts" \
|
||||||
|
"$(comment jakman85 "$AFTER" 'some preamble
|
||||||
|
/approve-override the real reason here')" jakman85
|
||||||
|
|
||||||
|
# --- staleness: an override must post-date the commit it waives review on -------------
|
||||||
|
expect_none "an override OLDER than the head commit is dropped as stale" \
|
||||||
|
"$(comment jakman85 "$BEFORE" '/approve-override stale, predates the push')"
|
||||||
|
expect_none "an override exactly AT the head commit time is dropped (strictly newer)" \
|
||||||
|
"$(comment jakman85 "$HEAD_TIME" '/approve-override same instant')"
|
||||||
|
|
||||||
|
# --- malformed input is never a free pass --------------------------------------------
|
||||||
|
expect_none "an unreadable timestamp drops the candidate" \
|
||||||
|
"$(comment jakman85 'not-a-date' '/approve-override bad clock')"
|
||||||
|
expect_none "a comment with no author is dropped" \
|
||||||
|
'[{"user": {}, "created_at": "2026-10-01T12:30:00Z", "body": "/approve-override no login"}]'
|
||||||
|
expect_none "invalid JSON yields no override (and never a traceback into stdout)" \
|
||||||
|
'this is not json'
|
||||||
|
expect_none "a JSON object instead of a list yields no override" \
|
||||||
|
'{"user": "nope"}'
|
||||||
|
|
||||||
|
# --- latest wins ----------------------------------------------------------------------
|
||||||
|
TWO='[
|
||||||
|
{"user": {"login": "early"}, "created_at": "2026-10-01T12:10:00Z", "body": "/approve-override first"},
|
||||||
|
{"user": {"login": "latest"}, "created_at": "2026-10-01T12:40:00Z", "body": "/approve-override second"}
|
||||||
|
]'
|
||||||
|
expect_user "the LATEST override supersedes an earlier one" "$TWO" latest
|
||||||
|
expect_reason "and brings its own reason" "$TWO" second
|
||||||
|
|
||||||
|
# --- offsets, not just Z --------------------------------------------------------------
|
||||||
|
# Gitea emits RFC3339 WITH AN OFFSET (measured: 2026-10-01T23:09:08-05:00), not a trailing Z.
|
||||||
|
# A parser that only handles 'Z' silently drops every real override, so this case is load-bearing.
|
||||||
|
expect_user "an RFC3339 offset timestamp (-05:00), as Gitea actually emits, parses" \
|
||||||
|
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T08:30:00-05:00", "body": "/approve-override offset form"}]' \
|
||||||
|
jakman85
|
||||||
|
expect_none "and an offset timestamp that is genuinely earlier is still stale" \
|
||||||
|
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T06:00:00-05:00", "body": "/approve-override offset, stale"}]'
|
||||||
|
|
||||||
|
# --- the directive is configurable ----------------------------------------------------
|
||||||
|
out="$(run "$(comment jakman85 "$AFTER" '/admin-waive because policy says so')" "$HEAD_TIME" '/admin-waive')"
|
||||||
|
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||||
|
ok "a custom directive from the declaration is honoured"
|
||||||
|
else
|
||||||
|
bad "a custom directive from the declaration is honoured (got '${out}')"
|
||||||
|
fi
|
||||||
|
expect_none "the default directive does not match a custom-directive comment" \
|
||||||
|
"$(comment jakman85 "$AFTER" '/admin-waive because policy says so')"
|
||||||
|
|
||||||
|
# --- no staleness anchor: do not invent one -------------------------------------------
|
||||||
|
out="$(COMMENTS_JSON="$(comment jakman85 "$BEFORE" '/approve-override no anchor')" \
|
||||||
|
HEAD_TIME="" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||||
|
ok "with no HEAD_TIME the staleness test is skipped, not faked (caller blocks instead)"
|
||||||
|
else
|
||||||
|
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- the REVIEWS source ---------------------------------------------------------------
|
||||||
|
# Load-bearing, not redundant. Measured on PR #38: SETTINGS_API_TOKEN is minted
|
||||||
|
# [read:repository, read:admin], /issues/{n}/comments needs the ISSUE scope, so a well-formed
|
||||||
|
# override in the comment box came back as an error object and was invisible. /pulls/{n}/reviews
|
||||||
|
# IS readable with that token, so a review body is the route that works today.
|
||||||
|
check "a review body carries an override" \
|
||||||
|
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||||
|
jakman85
|
||||||
|
check "the source is reported as 'review'" \
|
||||||
|
"$(cut -f3 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||||
|
review
|
||||||
|
check "the source is reported as 'comment' for a comment" \
|
||||||
|
"$(cut -f3 <<<"$(run "$(comment jakman85 "$AFTER" '/approve-override via a comment')")")" \
|
||||||
|
comment
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$BEFORE" COMMENT '/approve-override stale review')")"
|
||||||
|
check "a stale review override is dropped too" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$AFTER" PENDING '/approve-override unsubmitted draft')")"
|
||||||
|
check "a PENDING (unsubmitted) review is NOT an act of anything" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
check "an APPROVED review carrying the directive still counts as an override" \
|
||||||
|
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" APPROVED '/approve-override belt and braces')")")" \
|
||||||
|
jakman85
|
||||||
|
|
||||||
|
out="$(run_rev "$(review jakman85 "$AFTER" COMMENT 'ordinary review prose, no directive')")"
|
||||||
|
check "an ordinary review body is not an override" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
# --- an unreadable source degrades to 'no candidates', never to a pass ----------------
|
||||||
|
# The real-world shape: Gitea answers with {"message": "token does not have ... scope"}.
|
||||||
|
ERR_OBJ='{"message":"token does not have at least one of required scope(s): [read:issue]"}'
|
||||||
|
out="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "an error OBJECT from the comments API yields no override" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
out="$(COMMENTS_JSON="$ERR_OBJ" \
|
||||||
|
REVIEWS_JSON="$(review jakman85 "$AFTER" COMMENT '/approve-override reviews still work')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "an unreadable COMMENTS source does not suppress a valid REVIEW override" \
|
||||||
|
"$(cut -f1 <<<"$out")" jakman85
|
||||||
|
|
||||||
|
# and it must SAY so, or a scope problem reads as 'you never posted one' (it did, on PR #38)
|
||||||
|
errout="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>&1 >/dev/null)"
|
||||||
|
case "$errout" in
|
||||||
|
*"not a list"*read:issue*) ok "the diagnostic names the scope Gitea complained about" ;;
|
||||||
|
*) bad "the diagnostic should quote Gitea's message (got: ${errout})" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- latest wins ACROSS the two sources ----------------------------------------------
|
||||||
|
out="$(COMMENTS_JSON="$(comment early "2026-10-01T12:10:00Z" '/approve-override from a comment')" \
|
||||||
|
REVIEWS_JSON="$(review later "2026-10-01T12:50:00Z" COMMENT '/approve-override from a review')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "the latest override wins across both sources (review newer)" "$(cut -f1 <<<"$out")" later
|
||||||
|
check "and its source is labelled" "$(cut -f3 <<<"$out")" review
|
||||||
|
|
||||||
|
out="$(COMMENTS_JSON="$(comment later "2026-10-01T12:50:00Z" '/approve-override from a comment')" \
|
||||||
|
REVIEWS_JSON="$(review early "2026-10-01T12:10:00Z" COMMENT '/approve-override from a review')" \
|
||||||
|
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "the latest override wins across both sources (comment newer)" "$(cut -f1 <<<"$out")" later
|
||||||
|
check "and that source is labelled too" "$(cut -f3 <<<"$out")" comment
|
||||||
|
|
||||||
|
# --- both sources absent entirely -----------------------------------------------------
|
||||||
|
out="$(COMMENTS_JSON="" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||||
|
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||||
|
check "no sources at all yields no override, and no crash" "${out:-<none>}" "<none>"
|
||||||
|
|
||||||
|
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||||
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
@@ -42,7 +42,16 @@ cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env
|
|||||||
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
|
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
|
||||||
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
|
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
|
||||||
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
|
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
|
||||||
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
|
#
|
||||||
|
# AND IT HAPPENED AGAIN, with `approval-policy` (fixed 2026-10-01). Same shape, same cost: the
|
||||||
|
# stage was added to gate.sh by PR #17, never added here, so 15 of 50 assertions failed on `main`
|
||||||
|
# — measured on 0c7e8dd before touching anything, to be sure it was not the change under test.
|
||||||
|
# The note above was written about the previous instance and did not prevent the next one, which
|
||||||
|
# says the comment is not the fix. THE FIX IS TO DERIVE THE LIST: it now comes from gate.sh's own
|
||||||
|
# `run_stage` calls, so a stage added there can no longer be silently missing here.
|
||||||
|
mapfile -t STAGES < <(sed -n 's/^run_stage[[:space:]]\+\([a-z0-9-]\+\).*/\1/p' "${REAL_CI_DIR}/gate.sh")
|
||||||
|
[ "${#STAGES[@]}" -ge 8 ] || { echo "could not parse stage names out of gate.sh (got ${#STAGES[@]})" >&2; exit 1; }
|
||||||
|
for stage in "${STAGES[@]}"; do
|
||||||
cat > "${CI}/${stage}.sh" <<'STUB'
|
cat > "${CI}/${stage}.sh" <<'STUB'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ data:
|
|||||||
# platform/keycloak/postgres-cluster.yaml but is rendered by platform-config, which is the ONLY
|
# platform/keycloak/postgres-cluster.yaml but is rendered by platform-config, which is the ONLY
|
||||||
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
||||||
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
|
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
|
||||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers),
|
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
|
||||||
|
# plex-remote + pihole: PLEX_L4_VIP),
|
||||||
|
# vpn-ingress (WG_CLIENT_CIDR),
|
||||||
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||||
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
||||||
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
||||||
@@ -175,6 +177,26 @@ data:
|
|||||||
# dev -> 192.168.1.56 (base default; next free after crafty .55 in dev's .53-.60 pool)
|
# dev -> 192.168.1.56 (base default; next free after crafty .55 in dev's .53-.60 pool)
|
||||||
# prod -> 192.168.2.23 (next free after crafty .22 in prod's .20-.30 pool; see clusters/prod)
|
# prod -> 192.168.2.23 (next free after crafty .22 in prod's .20-.30 pool; see clusters/prod)
|
||||||
PIHOLE_DNS_VIP: "192.168.1.56" # PiHole: authoritative LAN DNS for ${BASE_DOMAIN}
|
PIHOLE_DNS_VIP: "192.168.1.56" # PiHole: authoritative LAN DNS for ${BASE_DOMAIN}
|
||||||
|
# Plex's remote-access listener: raw tcp/32400 for REMOTE Plex clients (apps/wave-0/plex-remote.yaml).
|
||||||
|
# A FOURTH pinned L4 VIP, for the usual reason — the `shared` Gateway is an L7 HTTPS terminator and
|
||||||
|
# cannot carry Plex's own TLS. Plex clients do not resolve `plex.${BASE_DOMAIN}`; they fetch this
|
||||||
|
# server's address from plex.tv, so the Gateway's L7 path (which still serves the web UI, and was
|
||||||
|
# measured working) cannot put the server on a client's connection list at all.
|
||||||
|
#
|
||||||
|
# PINNED, AND THE PIN IS LOAD-BEARING OFF-REPO: the SOHO router forwards WAN tcp/32400 to this
|
||||||
|
# address by hand. A VIP taken from the pool head would silently move on a reschedule and break
|
||||||
|
# remote access with nothing in-cluster looking wrong. See runbooks/plex-remote-access.md.
|
||||||
|
#
|
||||||
|
# dev -> 192.168.1.57 (base default; next free after pihole-dns .56 in dev's .53-.60 pool)
|
||||||
|
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
|
||||||
|
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
|
||||||
|
# SECOND CONSUMER since 2026-10-04: catalog/pihole.yaml reads this knob to answer
|
||||||
|
# plex-vip.${BASE_DOMAIN} on the LAN, which is the local half of the single hostname Plex
|
||||||
|
# advertises to plex.tv. Without that per-host answer the zone wildcard sends the name to
|
||||||
|
# ${GATEWAY_VIP}, where nothing listens on 32400. So this value is now typed into THREE places
|
||||||
|
# that must agree: this knob, the WAN port-forward on the SOHO router (off-repo), and PiHole's
|
||||||
|
# answer -- and only the first two were true before.
|
||||||
|
|
||||||
|
|
||||||
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
|
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
|
||||||
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
||||||
@@ -186,6 +208,31 @@ data:
|
|||||||
# prod -> 192.168.2.0/24 (prod LAN; nodes .10/.11/.12)
|
# prod -> 192.168.2.0/24 (prod LAN; nodes .10/.11/.12)
|
||||||
LAN_CIDR: "192.168.1.0/24" # ingress scope for the PiHole DNS listener
|
LAN_CIDR: "192.168.1.0/24" # ingress scope for the PiHole DNS listener
|
||||||
|
|
||||||
|
# --- Remote-access VPN client subnet (platform/vpn-ingress, WireGuard) --------------------------
|
||||||
|
# The tunnel subnet handed to REMOTE VPN clients. `.1` is the server (the hostNetwork DaemonSet,
|
||||||
|
# on every node); `.2`-`.254` are devices, allocated one /32 each in
|
||||||
|
# platform/vpn-ingress/peers.yaml. Consumed twice in the pod's config-assembly script: to derive
|
||||||
|
# the server's own address, and as the `-s` match of the MASQUERADE rule that makes LAN replies
|
||||||
|
# routable.
|
||||||
|
#
|
||||||
|
# IT IS A KNOB RATHER THAN A LITERAL FOR ONE REASON: it is the only address range in this cluster
|
||||||
|
# that must be checked against EVERY OTHER range at once, so it belongs where those are declared.
|
||||||
|
# 10.90.0.0/24 was chosen to collide with none of them:
|
||||||
|
# 10.0.0.0/16 Cilium cluster-pool pod CIDR (${CLUSTER_POD_CIDR})
|
||||||
|
# 10.43.0.0/16 k3s service CIDR (measured live 2026-10-05: kubernetes svc
|
||||||
|
# clusterIP 10.43.0.1)
|
||||||
|
# 10.72.192.0/18 the PIA tunnel's pushed addresses (platform/vpn-egress)
|
||||||
|
# 192.168.1.0/24 dev LAN / pfSense LAN (${LAN_CIDR} base default)
|
||||||
|
# 192.168.2.0/24 prod LAN (${LAN_CIDR} on prod)
|
||||||
|
#
|
||||||
|
# NO PER-ENVIRONMENT OVERRIDE, and that is deliberate rather than an omission: unlike every VIP
|
||||||
|
# above, this range is NOT drawn from the LAN. It is private to the tunnel, so the same value is
|
||||||
|
# correct in both environments and a prod patch would be a no-op that implies otherwise.
|
||||||
|
#
|
||||||
|
# IF YOU CHANGE IT, every already-issued client profile breaks (its `Address` is from this range)
|
||||||
|
# and must be re-rendered — so treat it as fixed once the first device is enrolled.
|
||||||
|
WG_CLIENT_CIDR: "10.90.0.0/24" # remote VPN clients; .1 = server. NOT a LAN range
|
||||||
|
|
||||||
# --- Bulk (external NFS) storage host (docs/04 bulk tier) ---------------------------------------
|
# --- Bulk (external NFS) storage host (docs/04 bulk tier) ---------------------------------------
|
||||||
# THE address of the Proxmox host whose ZFS datasets back every `class: bulk` volume: the media
|
# THE address of the Proxmox host whose ZFS datasets back every `class: bulk` volume: the media
|
||||||
# library (Storage/plex, 8.33 T — plex, sonarr, radarr) and the legacy Nextcloud archive
|
# library (Storage/plex, 8.33 T — plex, sonarr, radarr) and the legacy Nextcloud archive
|
||||||
|
|||||||
@@ -71,4 +71,11 @@ resources:
|
|||||||
# controller must NEVER gate the platform chain; same non-gating rationale as headlamp/oauth2-proxy.
|
# controller must NEVER gate the platform chain; same non-gating rationale as headlamp/oauth2-proxy.
|
||||||
# Carries velero-cloud.sops.yaml, so clusters/dev adds a SOPS decryption patch for it (by name).
|
# Carries velero-cloud.sops.yaml, so clusters/dev adds a SOPS decryption patch for it (by name).
|
||||||
- backup.yaml # Flux Kustomization -> ./platform/velero (dependsOn platform-config)
|
- backup.yaml # Flux Kustomization -> ./platform/velero (dependsOn platform-config)
|
||||||
|
# WireGuard remote-access VPN (north-south INGRESS, udp/51820) — POST-platform tier
|
||||||
|
# (dependsOn platform-config, wait:false). NOT the same thing as platform/vpn-egress, which sends
|
||||||
|
# pods OUT through PIA; this lets remote CLIENTS IN. Non-gating is load-bearing rather than
|
||||||
|
# conventional here: its DaemonSet cannot start until the operator creates the out-of-band
|
||||||
|
# `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have
|
||||||
|
# stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch.
|
||||||
|
- vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config)
|
||||||
- cluster-vars.yaml # overlay knob surface (base defaults)
|
- cluster-vars.yaml # overlay knob surface (base defaults)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
# Flux Kustomization: WireGuard remote-access VPN — reconciles ./platform/vpn-ingress.
|
||||||
|
#
|
||||||
|
# POST-PLATFORM TIER (dependsOn platform-config), NON-GATING (wait:false). Not a line in
|
||||||
|
# platform/kustomization.yaml, for the standard reason plus one specific to this component:
|
||||||
|
#
|
||||||
|
# * STANDARD: a leaf must never sit in the gating `platform` tier. While `platform` is not-Ready
|
||||||
|
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
|
||||||
|
# (DECISIONS-LOG: "Headlamp gating stall").
|
||||||
|
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
|
||||||
|
# operator creates the out-of-band `wg-server-key` Secret (runbooks/vpn-remote-access.md
|
||||||
|
# step 1). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
|
||||||
|
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
|
||||||
|
# cause (a missing manual step) invisible from any Flux condition.
|
||||||
|
#
|
||||||
|
# WHY dependsOn platform-config AND NOT cilium. This component needs no Gateway, no TLS cert, no
|
||||||
|
# Keycloak and no LB-IPAM pool — it has no HTTPRoute and no LoadBalancer Service, and hostNetwork
|
||||||
|
# means it does not even need a Cilium endpoint. It could legally reconcile much earlier. It is
|
||||||
|
# pinned behind platform-config anyway so that the ONE thing remote clients actually consume
|
||||||
|
# through it — split-horizon DNS and the shared Gateway — exists by the time the tunnel does.
|
||||||
|
# A tunnel that comes up before the things on the far side of it is a support call, not a feature.
|
||||||
|
#
|
||||||
|
# wait:false: nothing in the graph dependsOn this, and its pods are legitimately not-Ready whenever
|
||||||
|
# the server key is absent or the `wireguard` kernel module has not been loaded on a node. Neither
|
||||||
|
# condition should be able to gate anything else, even if a future resource is mistakenly pointed
|
||||||
|
# at it.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: vpn-ingress
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
retryInterval: 2m
|
||||||
|
timeout: 5m
|
||||||
|
prune: true
|
||||||
|
wait: false
|
||||||
|
dependsOn:
|
||||||
|
- name: platform-config
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
path: ./platform/vpn-ingress
|
||||||
|
# REQUIRED, not boilerplate: platform/vpn-ingress/daemonset.yaml renders ${WG_CLIENT_CIDR} into
|
||||||
|
# the config-assembly script. Without this block that token becomes the EMPTY STRING and the
|
||||||
|
# Kustomization still reports Ready (Flux's substitution fails OPEN — ci:
|
||||||
|
# flux-postbuild-substitution), leaving an initContainer that derives a server address of ".1/"
|
||||||
|
# and a MASQUERADE rule with no source. Both fail loudly at pod start, but the Kustomization
|
||||||
|
# would look healthy.
|
||||||
|
postBuild:
|
||||||
|
substituteFrom:
|
||||||
|
- kind: ConfigMap
|
||||||
|
name: cluster-vars
|
||||||
|
# NO `decryption:` stanza is needed in the overlays: this path carries no *.sops.yaml. The server
|
||||||
|
# key is an out-of-band Secret (platform/vpn-ingress/kustomization.yaml says what to do if that
|
||||||
|
# ever changes).
|
||||||
@@ -476,6 +476,26 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/PIHOLE_DNS_VIP
|
path: /data/PIHOLE_DNS_VIP
|
||||||
value: "192.168.2.23"
|
value: "192.168.2.23"
|
||||||
|
# Plex's remote-access listener VIP — raw tcp/32400 for REMOTE Plex clients
|
||||||
|
# (apps/wave-0/plex-remote.yaml). 192.168.2.24 is the NEXT FREE address in the prod pool
|
||||||
|
# 192.168.2.20-.30, verified live 2026-10-03: the CiliumLoadBalancerIPPool `default-pool`
|
||||||
|
# reported IPsTotal 11 / IPsUsed 4 / IPsAvailable 7, and the only four allocations are .20
|
||||||
|
# (cilium-gateway-shared), .21 (unifi-l4-tcp/-udp), .22 (crafty-l4-tcp/-udp) and .23
|
||||||
|
# (pihole-dns-tcp/-udp). It is inside the pool block the operator already asserted sits outside
|
||||||
|
# the SOHO router's DHCP scope, and it is not a node address (.10/.11/.12) or the
|
||||||
|
# control-plane VIP (.9).
|
||||||
|
#
|
||||||
|
# UNLIKE .23, THIS ONE IS ALSO A WAN PORT-FORWARD TARGET. The router forwards WAN tcp/32400
|
||||||
|
# here, by hand, off-repo — so changing this value is a two-place edit and the router is the
|
||||||
|
# half that CI cannot check. runbooks/plex-remote-access.md is the procedure.
|
||||||
|
#
|
||||||
|
# NOT ARP-PROBED. .23 was verified free with `arping` from a LAN host before it was claimed;
|
||||||
|
# for .24 only the in-cluster pool accounting above was checked, so an off-cluster squatter on
|
||||||
|
# this address would not have been caught. Probe before trusting it if remote access comes up
|
||||||
|
# with a conflict.
|
||||||
|
- op: replace
|
||||||
|
path: /data/PLEX_L4_VIP
|
||||||
|
value: "192.168.2.24"
|
||||||
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
|
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
|
||||||
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
|
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
|
||||||
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
|
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — `--api-route` is PER APP, ANCHORED, and DECLARED
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# THE DEFECT THIS CLOSES. oauth2-proxy answers an unauthenticated request with a 302 to
|
||||||
|
# Keycloak. Its built-in AJAX guard would send a 401 instead, but it fires only on an EXACT
|
||||||
|
# `Accept: application/json`, which these apps' JavaScript does not consistently send. So a
|
||||||
|
# browser XHR gets a 302, FOLLOWS IT INVISIBLY, has it satisfied silently by the live Keycloak
|
||||||
|
# SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
|
||||||
|
# prompt, no console error, nothing logged as a failure. On a WRITE it is worse: a browser
|
||||||
|
# following a 302 re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
|
||||||
|
# discarded and the UI reports success.
|
||||||
|
#
|
||||||
|
# MEASURED, twice, on two different apps and two different paths:
|
||||||
|
# jackett 2026-09-30 — on /api/ paths: 33 x 302 against 2 x 401, and 4 of 6 POSTs answered BY
|
||||||
|
# THE PROXY, never reaching Jackett (access-log upstream field `-`, not `/`). That is
|
||||||
|
# the operator's whole report: "GET works, most PUT/POST to add indexers fail,
|
||||||
|
# reloading never fixes it, and I am never re-prompted for login".
|
||||||
|
# radarr 2026-10-01, 23h after the cookie fix rolled (pod radarr-7d55cbffb9-42hmh, 0
|
||||||
|
# restarts) — 232 unauthenticated requests in the pod's whole lifetime and ALL 232 are
|
||||||
|
# the same call: `POST /signalr/messages/negotiate` from a browser UA, 302, upstream
|
||||||
|
# `-`. A left-open tab whose session the cookie rename orphaned, polling SignalR
|
||||||
|
# forever, silently redirected every time, NEVER re-prompting. The reported symptom,
|
||||||
|
# still running in production, on a service nobody was watching.
|
||||||
|
#
|
||||||
|
# WHY IT CANNOT BE A RENDERER CONSTANT — AND WHY THAT NEEDS A GATE. The path shape belongs to
|
||||||
|
# the APP, not to the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
|
||||||
|
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One function renders all five sidecars
|
||||||
|
# (catalog/render.py::_oauth2_sidecar_container), so the cheap fix is a constant — and a
|
||||||
|
# constant applies one app's measurement to four apps nobody measured. The gate that already
|
||||||
|
# exists cannot see that mistake: `ci/render-golden.sh` compares the render to the goldens, and
|
||||||
|
# a constant plus regenerated goldens AGREE WITH EACH OTHER while both being wrong. Hence
|
||||||
|
# assertion (2) below, which compares the rendered flag against each service's OWN
|
||||||
|
# ServiceDefinition rather than against the renderer's output.
|
||||||
|
#
|
||||||
|
# WHY BREADTH IS THE DANGEROUS DIRECTION, AND IT IS NOT SYMMETRIC. A MISSING --api-route is a
|
||||||
|
# silent 302 — bad, invisible, recoverable. A TOO BROAD one answers a plain PAGE LOAD with 401:
|
||||||
|
# a logged-out operator gets a bare error page and is NEVER OFFERED A SIGN-IN, so the service
|
||||||
|
# looks permanently broken and no reload recovers it. That is a worse outcome than the defect
|
||||||
|
# the flag fixes, which is why the anchoring rule is asserted on three independent surfaces
|
||||||
|
# (schema items.pattern, the renderer, and this check) and why assertion (5) fails if they drift
|
||||||
|
# apart — if they do, the loosest one wins silently.
|
||||||
|
#
|
||||||
|
# GIVEN every oauth2-proxy sidecar this repo DEPLOYS (a container in any manifest at any depth:
|
||||||
|
# the rendered sidecars in apps/, crafty's hand-authored one in platform/), the
|
||||||
|
# ServiceDefinitions in catalog/ that generate five of them, and the renderer itself,
|
||||||
|
# WHEN each sidecar's `--api-route` values are collected (the flag is REPEATABLE, so all of
|
||||||
|
# them, in order) and each onboarded forward-auth service is paired with its golden,
|
||||||
|
# THEN (1) every rendered --api-route is anchored at `^/` and continues with literal path
|
||||||
|
# characters — `^/`, `^/.*`, `.*`, an unanchored `/api/` and any alternation FAIL;
|
||||||
|
# (2) every onboarded forward-auth service's rendered route list EQUALS its own
|
||||||
|
# ServiceDefinition's `auth.apiRoutes`, in order — which fails both a hand-edited
|
||||||
|
# golden and a renderer constant, neither of which render-golden can see;
|
||||||
|
# (3) BOTH cases are live in this tree: at least one service declares routes and at least
|
||||||
|
# one declares none and renders none (the pihole case), so neither path is an
|
||||||
|
# unexercised seam;
|
||||||
|
# (4) `_oauth2_sidecar_container()` reads `auth.apiRoutes` and contains NO literal path
|
||||||
|
# regex — a constant is the defect, back in the one place that reintroduces it
|
||||||
|
# fleet-wide in a single edit;
|
||||||
|
# (5) the anchoring pattern in the renderer and in the schema are the SAME pattern this
|
||||||
|
# check enforces;
|
||||||
|
# (6) the SHIPPED renderer, run as a subprocess on synthetic ServiceDefinitions, emits
|
||||||
|
# exactly the declared routes in order, emits NOTHING for a service declaring none or
|
||||||
|
# an empty list, and REFUSES all five breadth shapes.
|
||||||
|
#
|
||||||
|
# WHY ALSO A SCHEMA PATTERN, GIVEN THIS CHECK. The schema is the only surface that rejects a
|
||||||
|
# too-broad regex WHERE A HUMAN TYPES IT — `ci/l0.sh` validates `catalog/*.yaml` against it on
|
||||||
|
# every gate run, so the operator is told at the authoring surface rather than finding out from
|
||||||
|
# a 401 on a page load. The renderer's own guard is there because `render-golden` runs even on a
|
||||||
|
# gate invocation where the schema stage skipped. This check is there because neither of those
|
||||||
|
# can see a renderer constant. Three surfaces, three different blind spots.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A SIBLING AND NOT PART OF oauth2-proxy-cookie-names-distinct. That check's subject
|
||||||
|
# is cookie IDENTITY: its inputs are names and domains, its failure mode is mutual session
|
||||||
|
# eviction, its register is the shared proxy's claim on the default name. `--api-route` shares
|
||||||
|
# none of those. Bundling them would make that check's name a lie and couple two unrelated
|
||||||
|
# ratchets, so a future api-route decision could only be expressed by weakening a COOKIE check —
|
||||||
|
# the same discipline that check's own header argues for, applied to this change.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES NOT ESTABLISH — read with the handoff entry, not instead of it:
|
||||||
|
# - That a 401 FIXES anything in a browser. No browser round-trip has been made, by anyone,
|
||||||
|
# for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll
|
||||||
|
# are all read from access logs; the repair is reasoned from HTTP semantics.
|
||||||
|
# - That the regexes are RIGHT for these two apps beyond the measured paths. `^/api/` on
|
||||||
|
# radarr is justified by a measurement of radarr taken on its PRE-FIX pod by another
|
||||||
|
# engineer (decisions/2026-09-30-jackett-oauth2-proxy-session.md: 62 x /api/v3/command,
|
||||||
|
# 46 x /api/v3/queue/details); the current pod has logged zero /api/ traffic.
|
||||||
|
# - Anything about sonarr, qbittorrent or pihole. Their logs were read (2026-10-01) and
|
||||||
|
# contain ZERO unauthenticated requests — no traffic, which is not the same as no need.
|
||||||
|
# Assertion (3) therefore proves the "declares none" PATH works; it does not claim those
|
||||||
|
# three apps are correctly configured.
|
||||||
|
# - That the LIVE pods carry what the tree says. git is not the cluster (CLAUDE.md §2b).
|
||||||
|
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: oauth2-proxy-api-routes-per-app
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-IDP-02: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-IDP-02"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
# WHOLE-REPO SCOPE — the subject spans apps/wave-0, apps/wave-1, platform/crafty, every
|
||||||
|
# ServiceDefinition in catalog/, the renderer that generates five of the sidecars, and the
|
||||||
|
# ServiceDefinition schema. No single requirement path-maps to that set, and the renderer
|
||||||
|
# maps to no identity requirement at all, so a requirement-scoped selection would skip this
|
||||||
|
# check on precisely the change that can reintroduce the defect fleet-wide in one edit.
|
||||||
|
conformance.home.k3s/scope: "whole-repo"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
An unauthenticated XHR is answered 401, not 302, on exactly the paths each app was
|
||||||
|
MEASURED to serve as a machine API: every rendered --api-route is anchored at ^/ and
|
||||||
|
continues with literal path characters, every onboarded forward-auth service's rendered
|
||||||
|
routes equal its own auth.apiRoutes in order (so neither a hand-edited golden nor a
|
||||||
|
renderer constant can pass), an app declaring none renders none, the anchoring pattern is
|
||||||
|
the same in the schema and the renderer, and the shipped renderer is proven on synthetic
|
||||||
|
ServiceDefinitions in both directions — including refusing the five breadth shapes that
|
||||||
|
would answer a page load with 401.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: assert-api-route-is-per-app-anchored-and-declared
|
||||||
|
description: >-
|
||||||
|
Inventory every oauth2-proxy sidecar's repeatable --api-route values, assert each is an
|
||||||
|
anchored path regex, cross-check each onboarded forward-auth service's rendered routes
|
||||||
|
against its own ServiceDefinition, require both the declares-routes and the
|
||||||
|
declares-none cases to be live in the tree, assert the renderer derives from
|
||||||
|
auth.apiRoutes and hardcodes no path regex, assert the schema and renderer share this
|
||||||
|
check's anchoring pattern, and prove the shipped renderer on synthetic
|
||||||
|
ServiceDefinitions in both directions.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC CHECK — `--api-route` is PER APP, ANCHORED, and DECLARED.
|
||||||
|
# See chainsaw-test.yaml's header for WHY this exists and what it measured. In one paragraph:
|
||||||
|
#
|
||||||
|
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. A browser XHR follows
|
||||||
|
# that redirect invisibly, the live SSO session satisfies it silently, and the call resolves 200
|
||||||
|
# carrying the app's HTML where its JavaScript expected JSON — and a POST following a 302 is
|
||||||
|
# re-issued as a GET with its BODY DROPPED. `--api-route=<regex>` makes matching paths answer
|
||||||
|
# 401 instead. The flag is right for jackett (`/api/`) and radarr (`/signalr/`, `/api/`) and
|
||||||
|
# WRONG for pihole, which serves no `/api/` at all — so it cannot be a renderer constant, and
|
||||||
|
# the point of this check is that it never becomes one again.
|
||||||
|
#
|
||||||
|
# The breadth direction is the dangerous one and it is NOT symmetric with the bug. A missing
|
||||||
|
# `--api-route` is a silent 302; a TOO BROAD one answers a plain PAGE LOAD with 401, so a
|
||||||
|
# logged-out operator gets a bare error page and is never offered a sign-in — the service looks
|
||||||
|
# permanently broken. That is a worse outcome than the defect, which is why the anchoring rule
|
||||||
|
# is asserted here as well as in the schema.
|
||||||
|
#
|
||||||
|
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
|
||||||
|
# into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if the
|
||||||
|
# tree contains no forward-auth oauth2-proxy sidecar at all — genuinely nothing to audit.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
# Honest SKIP (never green for a scope-declaring test) if the forward-auth pattern has left the
|
||||||
|
# repo. Anchored on `image:` for the same reason as the sibling cookie check: a bare
|
||||||
|
# `oauth2-proxy/oauth2-proxy:` also matches platform/oauth2-proxy/forward-auth-cec.yaml's Envoy
|
||||||
|
# cluster reference (`serviceName: oauth2-proxy/oauth2-proxy:80`), which runs no proxy.
|
||||||
|
if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy' \
|
||||||
|
"${ROOT}/apps" "${ROOT}/platform" >/dev/null 2>&1; then
|
||||||
|
echo "check SKIPPED: no oauth2-proxy container image found under apps/ or platform/ — the forward-auth sidecar pattern is gone and there is nothing to audit" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
"""Static audit: --api-route is per-app, anchored, and declared in the ServiceDefinition.
|
||||||
|
|
||||||
|
PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than
|
||||||
|
line-based parsing: a container's args live in a nested structure, and a line-based reader of
|
||||||
|
one is precisely the approximate parser that silently stops matching.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
||||||
|
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# THE BREADTH RATCHET, stated once and asserted against three independent surfaces below (the
|
||||||
|
# rendered tree, the authored catalog, the shipped renderer). Each entry must be anchored at
|
||||||
|
# `^/` and continue with LITERAL path characters. This rejects:
|
||||||
|
# `^/` and `^/.*` — match every page load, so the UI answers 401 and never signs in
|
||||||
|
# `/api/` — unanchored; oauth2-proxy would match it mid-path
|
||||||
|
# `.*` — everything
|
||||||
|
# `^/api/|^/` — alternation smuggles breadth past a prefix-only reading
|
||||||
|
# Alternation is REFUSED rather than supported because `--api-route` is repeatable: one entry
|
||||||
|
# per route costs nothing and each entry stays readable. Kept byte-identical to
|
||||||
|
# catalog/render.py's OAUTH2_API_ROUTE_PATTERN and to the schema's items.pattern; assertion (5)
|
||||||
|
# below fails if they drift apart.
|
||||||
|
ANCHORED = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
|
||||||
|
SCHEMA_PATTERN = r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$"
|
||||||
|
|
||||||
|
|
||||||
|
def read(p: Path) -> str:
|
||||||
|
try:
|
||||||
|
return p.read_text()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_test_fixture(p: Path) -> bool:
|
||||||
|
"""conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests."""
|
||||||
|
parts = p.parts
|
||||||
|
return "conformance" in parts and ("golden" in parts or "fixtures" in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_containers(node):
|
||||||
|
"""Yield every container mapping under any `containers`/`initContainers` list, at any depth.
|
||||||
|
|
||||||
|
Depth-agnostic on purpose: the same container shape appears under a Deployment, a DaemonSet,
|
||||||
|
a CronJob's jobTemplate or a bare Pod, and a reader that hardcoded one path would silently
|
||||||
|
stop seeing an instance the day one moved.
|
||||||
|
"""
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, val in node.items():
|
||||||
|
if key in ("containers", "initContainers") and isinstance(val, list):
|
||||||
|
for c in val:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
yield c
|
||||||
|
else:
|
||||||
|
yield from iter_containers(val)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
yield from iter_containers(item)
|
||||||
|
|
||||||
|
|
||||||
|
def api_routes_of(args: list) -> list:
|
||||||
|
"""Every `--api-route=<v>` / `--api-route <v>` value, in order. The flag is REPEATABLE, so
|
||||||
|
this must collect ALL of them — a single-value reader would audit the first and wave the
|
||||||
|
rest through, which is the shape of reader bug this file's header warns about."""
|
||||||
|
out = []
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if not isinstance(a, str):
|
||||||
|
continue
|
||||||
|
if a.startswith("--api-route="):
|
||||||
|
out.append(a.split("=", 1)[1])
|
||||||
|
elif a == "--api-route" and i + 1 < len(args) and isinstance(args[i + 1], str):
|
||||||
|
out.append(args[i + 1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ (0) INVENTORY: THE RENDERED TREE
|
||||||
|
# Every oauth2-proxy container this repo DEPLOYS, keyed by file + owner, with its api-routes.
|
||||||
|
rendered: dict[str, list] = {}
|
||||||
|
for p in sorted(ROOT.rglob("*.yaml")):
|
||||||
|
relparts = p.relative_to(ROOT).parent.parts
|
||||||
|
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
|
||||||
|
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs,
|
||||||
|
# so a bare rglob audits up to 18 duplicates of every file — which both invents FAILs and,
|
||||||
|
# worse, can satisfy a "something declares X" check from a copy rather than from real source.
|
||||||
|
if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name:
|
||||||
|
continue
|
||||||
|
txt = read(p)
|
||||||
|
if "oauth2-proxy" not in txt:
|
||||||
|
continue
|
||||||
|
rel = str(p.relative_to(ROOT))
|
||||||
|
try:
|
||||||
|
docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)]
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
check(False, f"{rel} is not parseable YAML ({exc}) — any oauth2-proxy instance in it "
|
||||||
|
f"went unaudited, so a too-broad --api-route could be hiding there")
|
||||||
|
continue
|
||||||
|
for d in docs:
|
||||||
|
for c in iter_containers(d):
|
||||||
|
image = str(c.get("image") or "")
|
||||||
|
name = str(c.get("name") or "")
|
||||||
|
if "oauth2-proxy" not in image and name != "oauth2-proxy":
|
||||||
|
continue
|
||||||
|
owner = (d.get("metadata") or {}).get("name") or "?"
|
||||||
|
args = list(c.get("args") or []) + list(c.get("command") or [])
|
||||||
|
rendered[f"{rel}:{owner}/{name or 'oauth2-proxy'}"] = api_routes_of(args)
|
||||||
|
|
||||||
|
check(len(rendered) >= 5,
|
||||||
|
f"only {len(rendered)} oauth2-proxy container(s) parsed out of the tree. This repo "
|
||||||
|
f"deploys more than that (five rendered sidecars plus crafty's hand-authored one), so the "
|
||||||
|
f"inventory has stopped seeing instances and every assertion below would pass vacuously "
|
||||||
|
f"while an unanchored --api-route went unaudited")
|
||||||
|
|
||||||
|
# ============================================================ (1) EVERY ROUTE IS ANCHORED
|
||||||
|
for key, routes in sorted(rendered.items()):
|
||||||
|
for r in routes:
|
||||||
|
check(bool(ANCHORED.match(r)),
|
||||||
|
f"{key} declares --api-route={r!r}, which is not an anchored path regex.\n"
|
||||||
|
f" WHY THIS IS WORSE THAN THE BUG IT FIXES: --api-route makes matching paths "
|
||||||
|
f"answer 401 instead of 302ing to Keycloak. A regex that also matches a path a "
|
||||||
|
f"BROWSER NAVIGATES to — `^/`, `^/.*`, `.*`, or an alternation that reaches the "
|
||||||
|
f"UI root — answers a plain PAGE LOAD with 401. A logged-out operator then sees a "
|
||||||
|
f"bare error page and is NEVER OFFERED A SIGN-IN: the service looks permanently "
|
||||||
|
f"broken rather than merely unauthenticated, and no reload can recover it.\n"
|
||||||
|
f" FIX: anchor it at '^/' followed by literal path characters "
|
||||||
|
f"(e.g. '^/api/', '^/signalr/', '^/api/v3/.*'), one entry per route — the flag is "
|
||||||
|
f"repeatable, so alternation is never needed. For a service rendered from catalog/, "
|
||||||
|
f"do NOT hand-edit the golden: declare auth.apiRoutes and re-render "
|
||||||
|
f"(ci/render-golden.sh's header has the canonical command)")
|
||||||
|
|
||||||
|
# ============================================================ (2) DERIVED FROM THE CATALOG
|
||||||
|
# The five rendered sidecars are OUTPUTS. Auditing only them would let someone "fix" a golden by
|
||||||
|
# hand (reverted by the next render) or make the renderer emit a CONSTANT and regenerate every
|
||||||
|
# golden to match — which render-golden cannot see, because render and golden would agree.
|
||||||
|
# So: every onboarded forward-auth service's rendered route list must EQUAL its own
|
||||||
|
# ServiceDefinition's auth.apiRoutes, in order.
|
||||||
|
catalog_dir = ROOT / "catalog"
|
||||||
|
declared: dict[str, list] = {}
|
||||||
|
forward_auth: list[str] = []
|
||||||
|
for sd_path in sorted(catalog_dir.glob("*.yaml")):
|
||||||
|
try:
|
||||||
|
sd = yaml.safe_load(read(sd_path))
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
check(False, f"catalog/{sd_path.name} is not parseable YAML ({exc})")
|
||||||
|
continue
|
||||||
|
if not isinstance(sd, dict) or not sd.get("name"):
|
||||||
|
continue
|
||||||
|
auth = sd.get("auth") or {}
|
||||||
|
routes = auth.get("apiRoutes") or []
|
||||||
|
declared[sd["name"]] = list(routes)
|
||||||
|
if auth.get("mode") == "forward-auth" and (sd.get("ingressRoutes") or []):
|
||||||
|
forward_auth.append(sd["name"])
|
||||||
|
for r in routes:
|
||||||
|
check(bool(ANCHORED.match(str(r))),
|
||||||
|
f"catalog/{sd_path.name}: auth.apiRoutes entry {r!r} is not anchored at '^/' "
|
||||||
|
f"followed by literal path characters. See assertion (1) for why breadth here is "
|
||||||
|
f"the dangerous direction. The schema rejects this too (items.pattern); this "
|
||||||
|
f"assertion exists because the schema stage can be SKIPPED and this one cannot")
|
||||||
|
if routes:
|
||||||
|
check(auth.get("mode") == "forward-auth",
|
||||||
|
f"catalog/{sd_path.name} declares auth.apiRoutes with auth.mode="
|
||||||
|
f"{auth.get('mode')!r}. --api-route renders onto the per-app oauth2-proxy sidecar, "
|
||||||
|
f"and no sidecar is rendered for oidc/native/none — so this is a SILENT NO-OP: the "
|
||||||
|
f"operator believes the app's XHRs now answer 401 and nothing changed")
|
||||||
|
|
||||||
|
matched = 0
|
||||||
|
for name in sorted(forward_auth):
|
||||||
|
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
||||||
|
if golden is None:
|
||||||
|
continue # catalog service not onboarded to apps/ yet — no golden
|
||||||
|
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
||||||
|
check(key is not None,
|
||||||
|
f"apps/{golden.name} is the golden for forward-auth service {name!r} but no "
|
||||||
|
f"oauth2-proxy container was parsed out of it. Its --api-route configuration is "
|
||||||
|
f"UNAUDITED")
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
matched += 1
|
||||||
|
check(rendered[key] == declared.get(name, []),
|
||||||
|
f"{name}: the rendered sidecar declares --api-route {rendered[key]!r} but "
|
||||||
|
f"catalog/{name}.yaml declares auth.apiRoutes {declared.get(name, [])!r}.\n"
|
||||||
|
f" These must be IDENTICAL and in order. A mismatch means one of two things, "
|
||||||
|
f"both bad: a golden was hand-edited (the next re-render silently reverts it), or the "
|
||||||
|
f"renderer is emitting --api-route from somewhere other than this service's own "
|
||||||
|
f"ServiceDefinition — i.e. a constant or a per-service table. A constant is the defect "
|
||||||
|
f"this field exists to prevent: it applies ONE app's path shape to all five, and "
|
||||||
|
f"pihole serves no /api/ at all, so it would answer pihole's page loads with 401. "
|
||||||
|
f"render-golden cannot catch that, because render and golden would agree with each "
|
||||||
|
f"other while both being wrong")
|
||||||
|
|
||||||
|
check(matched >= 4,
|
||||||
|
f"only {matched} onboarded forward-auth service(s) were cross-checked against their "
|
||||||
|
f"ServiceDefinition. This repo onboards five, so the catalog<->golden pairing has stopped "
|
||||||
|
f"resolving and assertion (2) is passing vacuously")
|
||||||
|
|
||||||
|
# ============================================================ (3) BOTH CASES EXIST
|
||||||
|
# Once the tree is consistent, (2) is satisfied by any pairing — including one where NOTHING
|
||||||
|
# declares a route (vacuous) or one where EVERYTHING does (so the "renders nothing" path is
|
||||||
|
# never exercised). Both halves are asserted to be live, because the per-app field only earns
|
||||||
|
# its complexity if both outcomes actually occur in this tree.
|
||||||
|
with_routes = [n for n in forward_auth if declared.get(n)]
|
||||||
|
without_routes = [n for n in forward_auth if not declared.get(n)]
|
||||||
|
check(bool(with_routes),
|
||||||
|
"no onboarded forward-auth service declares auth.apiRoutes, so assertion (2) cannot "
|
||||||
|
"distinguish a working renderer from one that emits the flag nowhere. If the flag is "
|
||||||
|
"genuinely not needed anywhere, delete the field rather than leaving an unexercised seam")
|
||||||
|
check(bool(without_routes),
|
||||||
|
"EVERY onboarded forward-auth service declares auth.apiRoutes, so nothing in this tree "
|
||||||
|
"exercises the 'declares none => renders no flag' path. That path is the whole reason the "
|
||||||
|
"field is per-app: pihole serves no /api/, and a renderer-wide default would answer its "
|
||||||
|
"page loads with 401")
|
||||||
|
for name in sorted(without_routes):
|
||||||
|
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
||||||
|
if golden is None:
|
||||||
|
continue
|
||||||
|
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
check(rendered[key] == [],
|
||||||
|
f"{name} declares NO auth.apiRoutes but its rendered sidecar carries "
|
||||||
|
f"--api-route {rendered[key]!r}. An app that declares nothing must render NOTHING")
|
||||||
|
|
||||||
|
# ============================================================ (4) THE RENDERER DERIVES
|
||||||
|
render_py = ROOT / "catalog" / "render.py"
|
||||||
|
check(render_py.is_file(),
|
||||||
|
"catalog/render.py is missing — the generator of five of these sidecars cannot be audited")
|
||||||
|
if render_py.is_file():
|
||||||
|
src = read(render_py)
|
||||||
|
m = re.search(r"def _oauth2_sidecar_container\(.*?\n(?=\n*def |\n*# =)", src, re.S)
|
||||||
|
fn = m.group(0) if m else ""
|
||||||
|
check(bool(fn),
|
||||||
|
"could not locate _oauth2_sidecar_container() in catalog/render.py. It was renamed or "
|
||||||
|
"removed, so assertion (4) cannot run and the generator is unaudited")
|
||||||
|
if fn:
|
||||||
|
check("--api-route=" in fn,
|
||||||
|
"catalog/render.py's _oauth2_sidecar_container() emits no --api-route at all, so "
|
||||||
|
"every forward-auth app's XHRs are answered with a 302 the browser follows "
|
||||||
|
"invisibly — measured as 33x302-vs-2x401 on jackett and 232x302 on radarr, with "
|
||||||
|
"POST bodies dropped. auth.apiRoutes would then be a field with no consumer")
|
||||||
|
check(not re.search(r'--api-route=\^/[A-Za-z0-9]', fn),
|
||||||
|
"catalog/render.py hardcodes a literal path regex into --api-route. Whatever path "
|
||||||
|
"that is, EVERY forward-auth app now renders it: one app's path shape applied to "
|
||||||
|
"five. pihole serves no /api/, so a constant answers its page loads with 401 and "
|
||||||
|
"the service looks permanently broken to a logged-out operator. Render the routes "
|
||||||
|
"declared in each service's own auth.apiRoutes instead")
|
||||||
|
check("apiRoutes" in fn,
|
||||||
|
"catalog/render.py's _oauth2_sidecar_container() does not read auth.apiRoutes, so "
|
||||||
|
"the per-app declaration has no effect on what is rendered")
|
||||||
|
|
||||||
|
# ============================================================ (5) ONE PATTERN, THREE SURFACES
|
||||||
|
# The ratchet is written in three places (this check, the renderer, the schema) because each
|
||||||
|
# guards a surface the others cannot. If they drift apart, the loosest one wins silently.
|
||||||
|
if render_py.is_file():
|
||||||
|
# The renderer writes the pattern as a raw string literal, so the source text contains it
|
||||||
|
# verbatim. A substring test is deliberate: it also catches a rename of the constant.
|
||||||
|
check(SCHEMA_PATTERN in read(render_py),
|
||||||
|
f"catalog/render.py no longer contains the anchoring pattern {SCHEMA_PATTERN!r} that "
|
||||||
|
f"this check and the schema enforce. Three surfaces guard --api-route breadth (the "
|
||||||
|
f"authoring surface via the schema, the render via the renderer, the tree via this "
|
||||||
|
f"check); if they drift apart the loosest wins and the ratchet is decorative")
|
||||||
|
|
||||||
|
# The schema's pattern is read from the PARSED JSON, not from the file text: in the file its
|
||||||
|
# backslashes are JSON-escaped, so a substring test against the raw bytes would compare the
|
||||||
|
# wrong string and pass or fail for the wrong reason.
|
||||||
|
schema_file = ROOT / "schemas" / "servicedefinition.schema.json"
|
||||||
|
schema_pat = None
|
||||||
|
if schema_file.is_file():
|
||||||
|
try:
|
||||||
|
schema_pat = (((((json.loads(read(schema_file)).get("properties") or {})
|
||||||
|
.get("auth") or {}).get("properties") or {})
|
||||||
|
.get("apiRoutes") or {}).get("items") or {}).get("pattern")
|
||||||
|
except (ValueError, AttributeError) as exc:
|
||||||
|
check(False, f"schemas/servicedefinition.schema.json could not be read as JSON ({exc}) — "
|
||||||
|
f"the schema stage that rejects a too-broad --api-route cannot be audited")
|
||||||
|
check(schema_pat == SCHEMA_PATTERN,
|
||||||
|
f"schemas/servicedefinition.schema.json constrains auth.apiRoutes items with "
|
||||||
|
f"{schema_pat!r}, not {SCHEMA_PATTERN!r}. The schema is the only surface that rejects a "
|
||||||
|
f"too-broad regex WHERE A HUMAN TYPES IT (ci/l0.sh validates catalog/*.yaml on every gate "
|
||||||
|
f"run); if it is absent or looser than the renderer's, the operator finds out from a 401 "
|
||||||
|
f"on a page load")
|
||||||
|
|
||||||
|
# ============================================================ (6) PROVE THE SHIPPED RENDERER
|
||||||
|
# Once the tree is compliant, (1)-(3) have nothing left to catch and their passing is evidence
|
||||||
|
# of nothing. This runs the SHIPPED renderer as a SUBPROCESS on synthetic ServiceDefinitions and
|
||||||
|
# asserts the behaviour directly, in BOTH directions, so the guard stays falsifiable.
|
||||||
|
def _sd(name: str, port: int, routes) -> str:
|
||||||
|
auth = {"mode": "forward-auth", "groups": ["platform-admins"]}
|
||||||
|
if routes is not None:
|
||||||
|
auth["apiRoutes"] = routes
|
||||||
|
return yaml.safe_dump({
|
||||||
|
"name": name,
|
||||||
|
"kind": "container",
|
||||||
|
"source": {"image": "example/app:1"},
|
||||||
|
"exposure": "internal",
|
||||||
|
"ingressRoutes": [{"host": f"{name}.home.keithjackson.dev", "port": port}],
|
||||||
|
"auth": auth,
|
||||||
|
}, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _render(name: str, port: int, routes):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
f = Path(td) / f"{name}.yaml"
|
||||||
|
f.write_text(_sd(name, port, routes))
|
||||||
|
return subprocess.run([sys.executable, str(render_py), str(f)],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_args(rendered_yaml: str) -> list:
|
||||||
|
for doc in yaml.safe_load_all(rendered_yaml):
|
||||||
|
if not isinstance(doc, dict) or doc.get("kind") != "Deployment":
|
||||||
|
continue
|
||||||
|
for c in iter_containers(doc):
|
||||||
|
if c.get("name") == "oauth2-proxy":
|
||||||
|
return list(c.get("args") or [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if render_py.is_file():
|
||||||
|
# (6a) DECLARED ROUTES RENDER, IN ORDER, AND NOTHING ELSE DOES.
|
||||||
|
proc = _render("apirtdecl", 8121, ["^/api/", "^/signalr/"])
|
||||||
|
check(proc.returncode == 0,
|
||||||
|
f"the shipped catalog/render.py failed on a synthetic forward-auth fixture declaring "
|
||||||
|
f"two apiRoutes: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
||||||
|
if proc.returncode == 0:
|
||||||
|
got = api_routes_of(_sidecar_args(proc.stdout))
|
||||||
|
check(got == ["^/api/", "^/signalr/"],
|
||||||
|
f"the shipped renderer turned auth.apiRoutes ['^/api/', '^/signalr/'] into "
|
||||||
|
f"--api-route {got!r}. It must emit one repeatable --api-route per entry, in "
|
||||||
|
f"declared order. Note the flag is SINGULAR: --api-routes does not exist in "
|
||||||
|
f"oauth2-proxy v7.8.1 and the proxy REFUSES TO START with it, so a plural would "
|
||||||
|
f"crashloop every sidecar it rendered")
|
||||||
|
|
||||||
|
# (6b) AN APP THAT DECLARES NOTHING RENDERS NOTHING. The pihole case, proven on the
|
||||||
|
# generator rather than inferred from one committed golden.
|
||||||
|
for label, routes in (("absent", None), ("empty", [])):
|
||||||
|
proc = _render(f"apirtnone{label}", 8122, routes)
|
||||||
|
check(proc.returncode == 0,
|
||||||
|
f"the shipped renderer failed on a synthetic forward-auth fixture with apiRoutes "
|
||||||
|
f"{label}: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
||||||
|
if proc.returncode == 0:
|
||||||
|
got = api_routes_of(_sidecar_args(proc.stdout))
|
||||||
|
check(got == [],
|
||||||
|
f"the shipped renderer emitted --api-route {got!r} for a synthetic service "
|
||||||
|
f"whose auth.apiRoutes is {label}. A newly-onboarded forward-auth app must "
|
||||||
|
f"render NO --api-route: pihole serves no /api/, so a default would answer "
|
||||||
|
f"its page loads with 401 and the service would look permanently broken to a "
|
||||||
|
f"logged-out operator — a worse failure than the 302 bug the flag fixes")
|
||||||
|
|
||||||
|
# (6c) THE RENDERER REFUSES BREADTH. Asserted on the generator because render-golden runs
|
||||||
|
# on every gate invocation while the schema stage can be skipped.
|
||||||
|
for bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
|
||||||
|
proc = _render("apirtbad", 8123, [bad])
|
||||||
|
check(proc.returncode != 0,
|
||||||
|
f"the shipped renderer ACCEPTED auth.apiRoutes {bad!r} and rendered a sidecar. "
|
||||||
|
f"That regex matches paths a browser NAVIGATES to, so a plain page load answers "
|
||||||
|
f"401 and the operator is never offered a sign-in. The renderer must refuse it, "
|
||||||
|
f"so a breadth mistake fails render-golden even on a run where the schema stage "
|
||||||
|
f"did not execute")
|
||||||
|
|
||||||
|
# ============================================================ VERDICT
|
||||||
|
inv = " | ".join(f"{k} -> {v or '(none)'}" for k, v in sorted(rendered.items()))
|
||||||
|
print(f"oauth2-proxy sidecars audited ({len(rendered)}): {inv}")
|
||||||
|
if FAILURES:
|
||||||
|
print(f"\nCHECK FAIL (oauth2-proxy-api-routes-per-app): {len(FAILURES)} failed, "
|
||||||
|
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" FAIL {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"check OK: oauth2-proxy-api-routes-per-app — {CHECKS} assertions (R-IDP-02)")
|
||||||
|
PY
|
||||||
@@ -129,6 +129,22 @@ KNOWN_EXTERNAL_MONITORS: dict[str, str] = {
|
|||||||
"a PodMonitor, so it resolves against POD ports, not Service ports — the Flux controller "
|
"a PodMonitor, so it resolves against POD ports, not Service ports — the Flux controller "
|
||||||
"Deployments (and their `http-prom` container port) are installed by the Flux bootstrap "
|
"Deployments (and their `http-prom` container port) are installed by the Flux bootstrap "
|
||||||
"manifests, not authored here. Live on prod: job flux-system/flux-system, 6 targets up.",
|
"manifests, not authored here. Live on prod: job flux-system/flux-system, 6 targets up.",
|
||||||
|
"platform/oauth2-proxy/podmonitor.yaml:oauth2-proxy-sidecars":
|
||||||
|
"a PodMonitor, so it resolves against POD ports, not Service ports. Unlike the other "
|
||||||
|
"entries here the target is NOT chart-owned — the `metrics` (44180) container port is "
|
||||||
|
"authored in this repo, on the oauth2-proxy sidecar in apps/wave-0/{jackett,qbittorrent,"
|
||||||
|
"pihole}.yaml and apps/wave-1/{radarr,sonarr}.yaml. It is unauditable here because NO "
|
||||||
|
"Service exposes 44180 BY DESIGN: adding one would mean changing catalog/render.py and "
|
||||||
|
"every apps/wave-* render and golden, to publish an endpoint only Prometheus should dial. "
|
||||||
|
"A PodMonitor needs no Service, which is the whole reason it was chosen. What assertion "
|
||||||
|
"(2) would have checked was instead MEASURED on prod 2026-10-01, read-only: the pods "
|
||||||
|
"declare ports [('http', 4180), ('metrics', 44180)], the selector label "
|
||||||
|
"homelab.keithjackson.dev/auth-mode=forward-auth is on exactly those five gate pods, and "
|
||||||
|
"port-forwarding 44180 returns http=200 with 10208 bytes of oauth2_proxy_* metrics. "
|
||||||
|
"CAVEAT, deliberately recorded: those targets are DOWN on prod, because the app "
|
||||||
|
"namespaces' default-deny NetworkPolicy drops the scrape from ns monitoring (same bug and "
|
||||||
|
"fingerprint as platform/longhorn/networkpolicy-metrics.yaml documents). The port name "
|
||||||
|
"resolves; the network path does not yet.",
|
||||||
}
|
}
|
||||||
|
|
||||||
MONITOR_KINDS = ("ServiceMonitor", "PodMonitor")
|
MONITOR_KINDS = ("ServiceMonitor", "PodMonitor")
|
||||||
|
|||||||
@@ -171,6 +171,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): paas-demo-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: paas-demo-allow-monitoring-scrape
|
||||||
|
namespace: paas-demo
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: paas-demo
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: paas-demo
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
+21
@@ -198,6 +198,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: sonarr-allow-monitoring-scrape
|
||||||
|
namespace: sonarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -198,6 +198,27 @@ spec:
|
|||||||
- port: '4180'
|
- port: '4180'
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
---
|
---
|
||||||
|
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||||
|
apiVersion: cilium.io/v2
|
||||||
|
kind: CiliumNetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: sonarr-allow-monitoring-scrape
|
||||||
|
namespace: sonarr
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
spec:
|
||||||
|
endpointSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: sonarr
|
||||||
|
ingress:
|
||||||
|
- fromEndpoints:
|
||||||
|
- matchLabels:
|
||||||
|
k8s:io.kubernetes.pod.namespace: monitoring
|
||||||
|
toPorts:
|
||||||
|
- ports:
|
||||||
|
- port: '44180'
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||||
apiVersion: cilium.io/v2
|
apiVersion: cilium.io/v2
|
||||||
kind: CiliumNetworkPolicy
|
kind: CiliumNetworkPolicy
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-30
|
||||||
|
branch: feat/oauth2-proxy-auth-failure-visible
|
||||||
|
title: The Jackett auth failure was invisible because the gates emit nothing — alert on the gap, not on a metric that does not exist
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Jackett's UI broke in the way that costs the most: **HTTP 200 and a plausible page**. The
|
||||||
|
operator's report named the property that matters — *"reloading the page never fixes the issue and
|
||||||
|
the user is never re-prompted for login"*. Two sibling branches fix the auth defects. This one is
|
||||||
|
about the fact that **no signal existed anywhere**, and it turns out that is not an oversight in
|
||||||
|
the alert rules. It is an absence of data.
|
||||||
|
|
||||||
|
**The measurement that decided the whole design** (prod, live Prometheus, 2026-09-30):
|
||||||
|
|
||||||
|
count by (__name__) ({__name__=~"oauth2_proxy.*"})
|
||||||
|
oauth2_proxy_requests_in_flight => 2
|
||||||
|
|
||||||
|
Two series. Both from the **shared** forward-auth Deployment in the `oauth2-proxy` namespace.
|
||||||
|
That is every oauth2-proxy metric this cluster holds. The six per-app gate sidecars — crafty,
|
||||||
|
jackett, pihole, qbittorrent, radarr, sonarr — publish **nothing at all**: they run with
|
||||||
|
`--http-address=0.0.0.0:4180` and no `--metrics-address`, so oauth2-proxy's metrics listener never
|
||||||
|
starts; they declare only a `http:4180` container port; and no ServiceMonitor or PodMonitor in the
|
||||||
|
cluster selects them.
|
||||||
|
|
||||||
|
So the alert the brief asked for — *"a gated app is serving a sustained rate of auth redirects to
|
||||||
|
a user who is authenticated"* — **is not writable**, and writing it anyway would have produced
|
||||||
|
exactly the artefact this repo keeps getting burned by: a rule that is present, evaluating,
|
||||||
|
permanently silent, and indistinguishable from health. It was not written.
|
||||||
|
|
||||||
|
What was written instead alerts on the condition that made the incident invisible, from families
|
||||||
|
that **are** scraped:
|
||||||
|
|
||||||
|
- **`platform/oauth2-proxy/auth-visibility-alerts.yaml`** — `OAuth2ProxyGateUnobserved`:
|
||||||
|
|
||||||
|
count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
|
||||||
|
unless
|
||||||
|
count by (namespace) (up{container="oauth2-proxy"})
|
||||||
|
|
||||||
|
`for: 1h`, `severity: warning`. `kube_pod_container_info` is kube-state-metrics (a declared
|
||||||
|
owner in `alert-metrics-have-scrape-targets`) and sees the container whether or not it exports
|
||||||
|
anything; `up` is UNIVERSAL and carries a `container` label for any scrape target, so the rule
|
||||||
|
needs no advance knowledge of what job name a future per-app scrape will use. Nothing in it
|
||||||
|
touches `oauth2_proxy_*`, so it cannot be inert for the reason it is reporting. **It returns six
|
||||||
|
namespaces against live prod right now** and excludes `oauth2-proxy`, which is scraped. It
|
||||||
|
clears per-namespace as each gate gains a real scrape.
|
||||||
|
- **`platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml`** — four states pinned:
|
||||||
|
fires for an unscraped namespace, never for the scraped one, honours `for: 1h` (pending at 59m),
|
||||||
|
and **stops** once a scrape appears under a *different* job name. Series shapes copied off live
|
||||||
|
prod, not invented.
|
||||||
|
- **`runbooks/oauth2-proxy-auth-failures.md`** — how to tell "the gate refused me" from "the group
|
||||||
|
check refused me" from "the app refused me" from "the XHR silently got HTML", with the
|
||||||
|
2026-09-30 jackett trace as the worked example. Registered in `runbooks/kustomization.yaml`'s
|
||||||
|
`configMapGenerator` and listed in `runbooks/README.md`.
|
||||||
|
- **`runbooks/README.md`** — a new row in *"Alerts you should not trust the silence of"* for the
|
||||||
|
per-app sidecars, plus the measured reason.
|
||||||
|
- **`platform/oauth2-proxy/README.md`** — what this component can and cannot see, and the exact
|
||||||
|
three-part change that would close the gap.
|
||||||
|
|
||||||
|
**Why the obvious alternative was rejected.** An alert on the *shared* proxy's
|
||||||
|
`oauth2_proxy_requests_total{code="302"}` ratio is the one a reader expects to see here. It would
|
||||||
|
need a new `SCRAPE_OWNERS` entry in `conformance/**` (a sensitive path needing human approval),
|
||||||
|
and — decisively — **it would not have fired on this incident**: the shared instance is not in the
|
||||||
|
data path for any sidecar-gated app, and its current pods export no `oauth2_proxy_requests_total`
|
||||||
|
at all, having proxied nothing since they started (the counter exists in the TSDB only from an
|
||||||
|
older ReplicaSet, `oauth2-proxy-78449b5657-*`, with `code` labels `302` and `401` and no `202`).
|
||||||
|
Shipping it would have looked like coverage of the reported failure and been none.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Everything below was measured against live prod through the pinned container, read-only. No
|
||||||
|
`apply`, no `edit`, no `patch`, no `flux reconcile`.
|
||||||
|
|
||||||
|
- **The full oauth2-proxy metric inventory.** `count by (__name__) ({__name__=~"oauth2_proxy.*"})`
|
||||||
|
→ `oauth2_proxy_requests_in_flight => 2`. `count by (__name__) ({job="oauth2-proxy"})` returns
|
||||||
|
go_*/process_*/promhttp_* plus that one family and nothing else. **Only the shared instance is
|
||||||
|
scraped**: `up{job="oauth2-proxy"}` → 2 targets, `10.0.1.189:44180` and `10.0.2.47:44180`, both
|
||||||
|
`1`, `namespace="oauth2-proxy"`.
|
||||||
|
- **The per-app sidecars are unscraped, and unscrapable as configured.** Every pod carrying an
|
||||||
|
`oauth2-proxy` container, with its declared ports: the two shared replicas have
|
||||||
|
`[('http',4180),('metrics',44180)]`; crafty, jackett, pihole, qbittorrent, radarr and sonarr all
|
||||||
|
have `[('http',4180)]` only. The live jackett sidecar's args contain no `--metrics-address`.
|
||||||
|
`kubectl get servicemonitors,podmonitors -A` lists 16 ServiceMonitors and 5 PodMonitors; the
|
||||||
|
only oauth2-proxy entry is `oauth2-proxy/oauth2-proxy`.
|
||||||
|
- **No existing alert is inert *for this reason*, because no alert references these metrics at
|
||||||
|
all.** `grep -rn oauth2_proxy --include=*.yaml` over the tree hits only cookie-name strings in
|
||||||
|
`platform/crafty/`. The 39 live PrometheusRules include `platform-health-identity`, whose four
|
||||||
|
rules are about Keycloak readiness and the client reconciler and are all keyed on `kube_*`
|
||||||
|
(scraped, fine). **The inertness here is the absence of a rule, not a broken one** — which is
|
||||||
|
why the runbook README's inert-alerts table gains a row phrased like the etcd one.
|
||||||
|
- **Keycloak cannot express it either.** `count by (__name__) ({__name__=~"keycloak_.*"})` →
|
||||||
|
empty, and empty over `[30d]`. The `keycloak-service` scrape yields JVM/Agroal/JGroups metrics
|
||||||
|
only; there are no user-event or login counters, so "a burst of re-logins for one client" is not
|
||||||
|
observable from the IdP side.
|
||||||
|
- **`oauth2_proxy_requests_total` has existed historically but not on the current pods.**
|
||||||
|
`count_over_time(oauth2_proxy_requests_total[30d])` returns three series, all from
|
||||||
|
`oauth2-proxy-78449b5657-*` (a prior ReplicaSet): `code="302"` on both replicas and `code="401"`
|
||||||
|
on one, ~634 samples each (~5h of a 30d window). **No `code="202"` ever.**
|
||||||
|
- **The alert expression fires against live data.** Run verbatim on prod:
|
||||||
|
`count by (namespace) (kube_pod_container_info{container="oauth2-proxy"}) unless count by
|
||||||
|
(namespace) (up{container="oauth2-proxy"})` → `pihole, jackett, crafty, qbittorrent, sonarr,
|
||||||
|
radarr`, value 1 each; `oauth2-proxy` correctly absent.
|
||||||
|
- **The log signal exists and is collected.** Loki (`loki-0`, SingleBinary) and a 3-node Alloy
|
||||||
|
DaemonSet are running, and Alloy labels pod logs with `namespace`/`container`. LogQL over 24h,
|
||||||
|
`{container="oauth2-proxy"}`: crafty 12036 lines, jackett 11669, oauth2-proxy 34559, pihole
|
||||||
|
11520, qbittorrent 11520, radarr 11763, sonarr 11520. Filtered to `|= "No valid authentication
|
||||||
|
in request"`: **crafty 81, jackett 39, radarr 67**; to `|= "AuthSuccess"`: **crafty 1, jackett
|
||||||
|
4, radarr 1**. Those ratios (81:1, 10:1, 67:1) are the incident's signature and are in the
|
||||||
|
runbook as the thing to compare against.
|
||||||
|
- **Why `kubectl logs` hid it.** The jackett sidecar's stdout is ~11.5k lines/day and almost all
|
||||||
|
of it is `GET "/ping" ... 200 2` from the kubelet probe, twice every 15s. A `--tail=40` shows
|
||||||
|
forty `/ping` lines and looks perfectly healthy. The runbook's commands all filter.
|
||||||
|
- **Runbook registration, both halves.** `- oauth2-proxy-auth-failures=oauth2-proxy-auth-failures.md`
|
||||||
|
is in `runbooks/kustomization.yaml`'s `configMapGenerator.files`, and the page is listed under
|
||||||
|
*Identity* in `runbooks/README.md`. The page clears the L0 quality floor (the four operator
|
||||||
|
sections, >1500 bytes, mentions `dev.sh` and `--context k3s-prod`), and the alert's
|
||||||
|
`runbook_url` points at exactly that name.
|
||||||
|
- **Gate:** run in the pinned toolbox from this worktree with the `ENGINE` shim. Exit code and
|
||||||
|
per-stage outcomes in the next section — read them there, not here.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The alert has never fired in Alertmanager.** Nothing is applied; the PrometheusRule exists
|
||||||
|
only in git on this branch. What is proven is that its expression returns the expected vector
|
||||||
|
against live prod data and that promtool agrees about its behaviour over synthetic series.
|
||||||
|
**Merging is what deploys it.**
|
||||||
|
- **`OAuth2ProxyGateUnobserved` would NOT have fired *during* the 2026-09-30 jackett incident in
|
||||||
|
any useful sense** — it describes a standing condition that has been true since the sidecars
|
||||||
|
were deployed, so it would have been firing before, during and after. That is deliberate and it
|
||||||
|
is the honest claim: it is a **gap tracker**, not an incident detector. It would have meant that
|
||||||
|
on 2026-09-30 an operator asking "why did nothing alert?" had the answer already on the board
|
||||||
|
instead of discovering it during the investigation. **No alert on this platform could have
|
||||||
|
detected the incident itself, and none can today.**
|
||||||
|
- **The alert that the brief actually asked for does not exist, and cannot be written yet.** The
|
||||||
|
sustained-redirect-ratio rule needs `oauth2_proxy_requests_total` from the *per-app* sidecars.
|
||||||
|
**Enabling that requires editing files owned by the two sibling branches** — the sidecar args in
|
||||||
|
`apps/wave-0/jackett.yaml` (Mira Vance) and `apps/wave-0/{qbittorrent,pihole}.yaml`,
|
||||||
|
`apps/wave-1/{radarr,sonarr}.yaml` (Theo Bright) — so it was **not attempted here**. The change
|
||||||
|
is three things per app, together: `--metrics-address=0.0.0.0:44180`, a `metrics` containerPort
|
||||||
|
plus Service port, and a ServiceMonitor. Once any one app has them, the rule belongs beside
|
||||||
|
`OAuth2ProxyGateUnobserved`, and that app's namespace drops out of it automatically.
|
||||||
|
- **The Loki alerting path is inert and I did not fix it.** Loki *has* a ruler stanza and the
|
||||||
|
chart's `loki-sc-rules` sidecar is running with `LABEL=loki_rule`, `FOLDER=/rules` — so a
|
||||||
|
ConfigMap-shipped LogQL rule would be delivered. But `GET /loki/api/v1/rules` answers **"unable
|
||||||
|
to read rule dir /var/loki/rules/fake: no such file or directory"** (the ruler reads a different
|
||||||
|
directory from the one the sidecar writes), and the rendered config contains **no
|
||||||
|
`alertmanager_url`** — `grep -i 'alertmanager\|rule_path'` over the `loki` ConfigMap returns
|
||||||
|
nothing. So a LogQL alert on `|= "No valid authentication in request"` — which *would* have
|
||||||
|
caught this incident, and is the single highest-value follow-up here — **would evaluate and
|
||||||
|
notify nobody**. Wiring it means `rulerConfig` (alertmanager_url + a matching rules directory)
|
||||||
|
in `platform/monitoring/loki.yaml`, which is not a file this brief gave me. **I did not ship an
|
||||||
|
inert rule ConfigMap to look productive.**
|
||||||
|
- **Nothing was reproduced in a browser.** The 302-to-200-with-HTML mechanism is read off the
|
||||||
|
sidecar access log and the response sizes (`/UI/Dashboard 200 44379`), not observed in devtools.
|
||||||
|
- **Loki retention is 72h** (`platform/monitoring/loki.yaml`), so the 24h ratios above are a
|
||||||
|
snapshot; anything older than three days is already gone. This is stated in the runbook because
|
||||||
|
it bounds what an operator can still investigate.
|
||||||
|
- **The gate's L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), so no runtime
|
||||||
|
requirement touched by this branch is certified. See the exit code below — **rc 3 is
|
||||||
|
UNDETERMINED, not green.**
|
||||||
|
|
||||||
|
### Follow-ups, in order of value
|
||||||
|
|
||||||
|
1. **Wire the Loki ruler to Alertmanager** (`platform/monitoring/loki.yaml`). This is the only
|
||||||
|
route to an alert that would have caught the actual 2026-09-30 failure, and the data is
|
||||||
|
already being collected. The rule is one expression:
|
||||||
|
`sum by (namespace) (rate({container="oauth2-proxy"} |= "No valid authentication in request" [15m]))
|
||||||
|
> 5 * sum by (namespace) (rate({container="oauth2-proxy"} |= "AuthSuccess" [15m]))`.
|
||||||
|
2. **Give one gate sidecar a metrics endpoint** and watch its namespace drop out of
|
||||||
|
`OAuth2ProxyGateUnobserved`. That is the cheapest proof the alert is a tracker and not a nag,
|
||||||
|
and it unlocks the ratio rule in Prometheus.
|
||||||
|
3. **Consider whether `--set-xauthrequest` apps should get `--api-routes`** so API paths answer
|
||||||
|
401 instead of 302. That is the fix that would make this failure *loud* rather than merely
|
||||||
|
observable, and it belongs with whoever owns the sidecar args.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Round 2 — 2026-10-01: the scrape got wired, and found a bug in this branch's own alert
|
||||||
|
|
||||||
|
Added to the same PR (#29) rather than a fourth concurrent branch. PR #28 merged in the meantime
|
||||||
|
and `main` moved three PRs ahead; `origin/main` is merged in here, and the `runbooks/kustomization.yaml`
|
||||||
|
conflict was resolved by **keeping both** runbook entries (mine and `oauth2-proxy-session`).
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**`platform/oauth2-proxy/podmonitor.yaml` (new).** PR #28 gave the five fleet sidecars
|
||||||
|
`--metrics-address=0.0.0.0:44180` and a named `metrics` port. No Service exposes 44180 on any of
|
||||||
|
them, so a ServiceMonitor would have nothing to select — a **PodMonitor** targets pod ports directly
|
||||||
|
and needs no renderer change. Selects on the pod label `homelab.keithjackson.dev/auth-mode:
|
||||||
|
forward-auth`, which is on exactly the five fleet gate pods and absent from crafty's: the property,
|
||||||
|
not an app list, so a sixth forward-auth app needs no edit.
|
||||||
|
|
||||||
|
**`auth-visibility-alerts.yaml`: `up{container="oauth2-proxy"}` → `up{container="oauth2-proxy"} == 1`.
|
||||||
|
This is a real bug fix, and it is the most important thing in this round.** A target that is
|
||||||
|
discovered but failing still publishes `up`, with value **0**. The series exists, so
|
||||||
|
`count by (namespace)` returned 1, `unless` removed the namespace, and `OAuth2ProxyGateUnobserved`
|
||||||
|
would have **gone quiet while nothing was being scraped at all** — this branch's own disease,
|
||||||
|
reproduced inside the alert written to cure it. Not hypothetical: the PodMonitor lands five targets
|
||||||
|
whose scrape is dropped, so the bare form would have silently cleared on this very commit.
|
||||||
|
|
||||||
|
**Two new unit-test scenarios** pinning that: a discovered-but-failing target must still fire, and a
|
||||||
|
target that works and then breaks must re-fire rather than latch cleared.
|
||||||
|
|
||||||
|
**Docs corrected rather than appended to.** The 2026-09-30 claim "the sidecars export no metrics at
|
||||||
|
all" is now only half true and was rewritten in `runbooks/README.md`, `platform/oauth2-proxy/README.md`
|
||||||
|
and the alert header, each showing the two-step history, because each step looked like the end of it.
|
||||||
|
The runbook gains the policy-vs-no-target triage (step 1b) and the **radarr `/signalr` worked case**.
|
||||||
|
|
||||||
|
**Deliberately NOT shipped: the ratio alert.** `oauth2_proxy_requests_total` now exists on the
|
||||||
|
endpoint, but not in the TSDB, because the scrape is blocked. Writing the ratio rule today would make
|
||||||
|
it inert for precisely the reason it was refused on 2026-09-30. It becomes writable the day a scrape
|
||||||
|
succeeds, not the day a listener exists.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **The endpoint serves — by payload, not by flag.** Port-forwarded to the jackett pod's 44180:
|
||||||
|
`http=200 bytes=10208`, exporting `oauth2_proxy_requests_in_flight`, `oauth2_proxy_requests_total`
|
||||||
|
(`code="200" 3`, `code="302" 2`) and `oauth2_proxy_response_duration_seconds`. `--metrics-address`
|
||||||
|
being in `args` and `/metrics` returning a body are different claims; both now hold.
|
||||||
|
- **The scrape path is DROPPED, measured from the right place.** From grafana in ns `monitoring`
|
||||||
|
(beside Prometheus, same policy position) to the jackett pod IP: `:44180` → `wget: download timed
|
||||||
|
out`; `:4180` → **also** timed out; the shared proxy's `:44180` → 200 with `oauth2_proxy_*` present.
|
||||||
|
Cause, read from the objects: ns jackett's `default-deny` is `podSelector: {}`,
|
||||||
|
`policyTypes: [Ingress, Egress]`, with an `egress` rule and **no `ingress` key** (deny-all ingress);
|
||||||
|
the only allowance is CNP `jackett-allow-gateway-ingress`, `fromEntities: [ingress]` to port 4180.
|
||||||
|
The shared proxy is scrapable only because ns `oauth2-proxy` has **no NetworkPolicy at all**.
|
||||||
|
- **No Service exposes 44180** on any of the five (enumerated).
|
||||||
|
- **The pod label selector is exact:** `auth-mode=forward-auth` on all five fleet gate pods, absent
|
||||||
|
from crafty's. Crafty's sidecar still has no `--metrics-address` (args read in full).
|
||||||
|
- **Flux ordering forbids shipping the fix from here:** `oauth2-proxy dependsOn=['platform-config']`
|
||||||
|
and `apps-wave-0 dependsOn=['platform-config']` — siblings, no ordering. A NetworkPolicy naming ns
|
||||||
|
`jackett` from this tier would apply today and fail on a cold rebuild.
|
||||||
|
- **The `== 1` guard is non-vacuous — proven by mutation.** Reverting it to the bare `up` makes the
|
||||||
|
two new scenarios FAIL (`got: nil` at 1h5m and 2h5m) and leaves every other assertion passing;
|
||||||
|
restoring it returns the suite to SUCCESS under pinned promtool v3.14.0.
|
||||||
|
- **Gate:** see the next section for the exit code and skips.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The PodMonitor has never been applied.** That it discovers the five pods is reasoned from the
|
||||||
|
measured pod labels and port name, not observed — no target list has been read with it in place,
|
||||||
|
because applying it is an operator action. **Expect `up == 0` on all five when it lands**, which is
|
||||||
|
the documented state, not a regression.
|
||||||
|
- **I did not fix the NetworkPolicy, and this PR therefore does not make the gates observable.** It
|
||||||
|
makes them *discovered*, and makes the remaining gap loud instead of silent. The one narrow rule
|
||||||
|
per app (ns monitoring → `auth-mode=forward-auth` → TCP 44180, per
|
||||||
|
`platform/longhorn/networkpolicy-metrics.yaml`) has to be rendered with each app's own policy, in
|
||||||
|
the catalog/renderer that is in flight on another branch. **It needs an owner.**
|
||||||
|
- **Whether Prometheus will actually select a PodMonitor in ns `oauth2-proxy` targeting other
|
||||||
|
namespaces.** `namespaceSelector.any: true` plus `podMonitorSelectorNilUsesHelmValues: false` and
|
||||||
|
the fact that cross-namespace ServiceMonitors/PrometheusRules already work here say yes; it is not
|
||||||
|
measured with this object.
|
||||||
|
- **No session-lifecycle claim can be made from the current window.** `AuthSuccess` is **0** on both
|
||||||
|
jackett and radarr since the PR #28 restart, so nobody has signed in and `--cookie-refresh=2m30s`
|
||||||
|
has never executed a refresh. The cookie fix is unproven in the one dimension that matters most.
|
||||||
|
- **The radarr `/signalr` finding is the coordinator's measurement, not mine** — I reproduced neither
|
||||||
|
the 206/209 sample nor the jackett 0-in-13,612. They are recorded as reported.
|
||||||
|
- **L2/L3 conformance still did not run** (`CLUSTER_AVAILABLE` unset). Nothing is certified.
|
||||||
|
|
||||||
|
### Round 2 addendum — CI is RED, by design, and the local gate cannot see why
|
||||||
|
|
||||||
|
After pushing round 2, `gate / gate (pull_request)` reported **failure after 1m42s** while the local
|
||||||
|
toolbox run was rc 3 with nothing failed. Measured cause, singular:
|
||||||
|
|
||||||
|
sensitive paths touched:
|
||||||
|
conformance/tests/l0-static/servicemonitor-ports-resolve/check.sh (matched conformance/**)
|
||||||
|
approving reviews: (no reviews at all) author=gitea-admin
|
||||||
|
|
||||||
|
`ci/approval-policy.sh` is fail-closed and exits 1 when a PR touches a `sensitive_paths` glob with no
|
||||||
|
`APPROVED` review from a non-author — "a change to the gate cannot be gated by the gate". The single
|
||||||
|
`KNOWN_EXTERNAL_MONITORS` entry is what trips it. **Nothing in L0/L1 failed.**
|
||||||
|
|
||||||
|
**The local gate structurally cannot catch this, and said `approval-policy: green (N/A)`.** The stage
|
||||||
|
short-circuits to N/A when `GITHUB_EVENT_NAME` is unset, which it always is off-CI. So for any branch
|
||||||
|
touching a sensitive path, a clean local gate is a FALSE REASSURANCE — the PR run is the only honest
|
||||||
|
signal. That is a general trap, not specific to this change, and it is worth knowing before someone
|
||||||
|
concludes a red PR means broken code.
|
||||||
|
|
||||||
|
To clear: a non-author approving review, then **re-run the gate** — approval does not re-trigger the
|
||||||
|
status check, so the required context stays red until the job runs again. `required_approvals=1`
|
||||||
|
already applied to every PR here, so the human was needed regardless; the conformance entry only
|
||||||
|
turns the tick red while that is pending.
|
||||||
|
|
||||||
|
The alternative is to drop the register entry, which means dropping `podmonitor.yaml` with it (the
|
||||||
|
check fails without the declaration). I did not take it: the entry is the "declare the limit"
|
||||||
|
mechanism the check explicitly asks for, it is shrink-only so it cannot rot quietly, and
|
||||||
|
`flux-podmonitor.yaml` is the same case already in the register. But it is a real trade and the
|
||||||
|
reviewer should make it knowingly.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-01
|
||||||
|
branch: fix/admin-approval-override
|
||||||
|
title: Administrators can merge their own green PR — required_approvals 1 → 0, plus an attributed /approve-override for sensitive paths
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**The problem, measured first.** `required_approvals: 1` on `main` was a lock-out, not a
|
||||||
|
protection. Gitea refuses to let a pull request's author approve their own PR, and `jakman85` is
|
||||||
|
the **only** collaborator (permission `owner`). So no account in existence could approve a PR the
|
||||||
|
operator opened. Measured on the live API, 2026-10-01:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| PR #35 (`bugfix/arr-to-plex`, author `jakman85`) | `gate / gate (pull_request)` = **success, 1m41s** |
|
||||||
|
| its reviews | `[]` |
|
||||||
|
| live `required_approvals` | `1` |
|
||||||
|
| live `block_admin_merge_override` | `true` |
|
||||||
|
|
||||||
|
Green gate, zero reviews, no path to a merge — and the same was true of any PR that would have
|
||||||
|
fixed it.
|
||||||
|
|
||||||
|
It was also never the design this repo wrote down. `ci/approval-policy.sh`'s own header and
|
||||||
|
`ci/gate.sh:194` both already described the model as *"`required_approvals` drops to 0 and the
|
||||||
|
GATE decides"*, and `infra/gitea/require-gate.sh`'s closing note named this exact failure and
|
||||||
|
prescribed this exact remedy. `decisions/2026-09-30-flux-deploy-notification-inhibited.md` had
|
||||||
|
flagged the 1-vs-0 disagreement as unresolved and not that session's to settle. This settles it
|
||||||
|
at 0.
|
||||||
|
|
||||||
|
**Two layers were blocking, and both are addressed.**
|
||||||
|
|
||||||
|
*Layer 1 — Gitea branch protection.* `required_approvals: 1 → 0` in
|
||||||
|
`infra/gitea/expected-branch-protection.json`, with the rationale in the file, plus
|
||||||
|
`infra/gitea/allow-admin-self-merge.sh` to apply it to the live rule (it reads the target **from
|
||||||
|
the declaration**, so the script cannot drift from what the gate checks). `require-gate.sh` was
|
||||||
|
changed to 0 as well — left at 1 it would silently re-impose the lock-out on its next run.
|
||||||
|
|
||||||
|
The narrow choice matters: the *other* way to unblock an owner is
|
||||||
|
`block_admin_merge_override: false`, and that would have re-opened precisely what
|
||||||
|
`infra/gitea/block-admin-override.sh` was written to close (PRs #5–#9 merged with zero approvals
|
||||||
|
while the settings read as enforced). This change drops the approval **count** only.
|
||||||
|
`block_admin_merge_override` and `enable_status_check` both stay `true`, and the script asserts
|
||||||
|
both afterwards and fails loudly if the PATCH disturbed either. **A red gate remains unmergeable
|
||||||
|
by everyone, administrators included.**
|
||||||
|
|
||||||
|
*Layer 2 — `ci/approval-policy.sh`.* Sensitive paths (`ci/**`, `infra/gitea/**`, `CLAUDE.md`,
|
||||||
|
kyverno, rbac, sops, Flux wiring) still require a non-author `APPROVED` review, which the sole
|
||||||
|
administrator still cannot obtain. An administrator may now waive it by commenting on the PR:
|
||||||
|
|
||||||
|
/approve-override <reason>
|
||||||
|
|
||||||
|
- the directive must **start a line** (a quoted or mid-sentence mention does not count, or
|
||||||
|
repeating someone else's override would re-arm it);
|
||||||
|
- a **reason is mandatory** — a bare directive records that review was waived but not why;
|
||||||
|
- the commenter's permission is **re-checked live** against
|
||||||
|
`/collaborators/{login}/permission` when the gate runs, so an old comment from a demoted
|
||||||
|
account carries nothing;
|
||||||
|
- it is **invalidated by a new commit** (the comment must post-date the head commit), mirroring
|
||||||
|
Gitea's own `dismiss_stale_approvals`;
|
||||||
|
- the overrider, timestamp and reason are printed in the gate log and the job summary, and a
|
||||||
|
self-waiver by the PR's own author is called out as such.
|
||||||
|
|
||||||
|
Selection of the candidate comment is `ci/approval-override.py` — pure, offline, no authority of
|
||||||
|
its own — so it could be unit-tested; authorisation stays in the fail-closed stage.
|
||||||
|
`ci/tests/approval-override.test.sh` adds **23 assertions**, all passing, each constructing a
|
||||||
|
case the parser must refuse. Every new failure mode blocks: unreadable head-commit timestamp,
|
||||||
|
unreadable permission, parser error.
|
||||||
|
|
||||||
|
**The cost, stated rather than buried.** For an administrator, *"a change to the gate cannot be
|
||||||
|
gated by the gate"* is now **advisory, not structural**. That is unavoidable with one human — the
|
||||||
|
alternative is a repository its owner cannot change — and what is bought back is an attributed,
|
||||||
|
reasoned, commit-scoped record of each waiver instead of a silent bypass.
|
||||||
|
|
||||||
|
**The uncomfortable part, accepted with eyes open (operator decision).** Authorisation is by live
|
||||||
|
permission **alone**, with no allowlist of human accounts. The **agent account `gitea-admin` also
|
||||||
|
reports permission `owner`** on this repo (measured), so an agent *can* clear the sensitive-path
|
||||||
|
review on its own PR — and agents author most PRs here, so that is the common case, not an edge
|
||||||
|
case. I raised this before implementing; the operator chose permission-only. It is documented in
|
||||||
|
three places rather than left to be discovered: `approval-policy.json`
|
||||||
|
(`_known_consequence_agent_accounts`), the stage's header, and `CLAUDE.md`, which now tells
|
||||||
|
agents plainly **not** to use it on their own work. The narrowing path needs no code change —
|
||||||
|
add an `override_admins` list to `approval-policy.json` and the stage already requires membership
|
||||||
|
in addition to the permission check.
|
||||||
|
|
||||||
|
Also updated: `CLAUDE.md` §1 and the §2 enforcement table (the "approval required" row is now
|
||||||
|
explicitly ⬜ with the reason, rather than a stale ✅), and `infra/gitea/README.md` gains §6 and
|
||||||
|
marks its historical step 4 as reversed — do not re-apply.
|
||||||
|
|
||||||
|
**Second pass, 2026-10-02 — the override did not work the first time, and why.** The operator ran
|
||||||
|
the live PATCH (`required_approvals: 0` confirmed live, with `block_admin_merge_override` and
|
||||||
|
`enable_status_check` still `true`), merged PR #35, and posted `/approve-override Need to ensure
|
||||||
|
approval override work as expected` on PR #38. The gate re-ran (run 203) and **still blocked.**
|
||||||
|
|
||||||
|
The comment was not at fault: posted by `jakman85`, directive at the start of the line, reason
|
||||||
|
present, and 29 minutes **newer** than head commit `616a497`, so it cleared the staleness rule.
|
||||||
|
What failed was that **CI could not read it.** Run 203's log:
|
||||||
|
|
||||||
|
approval-override: COMMENTS_JSON is not a list
|
||||||
|
|
||||||
|
`SETTINGS_API_TOKEN` is minted `["read:repository", "read:admin"]` by
|
||||||
|
`infra/gitea/set-ci-token.sh`, and Gitea puts `/issues/{n}/comments` behind the **issue** scope. So
|
||||||
|
Gitea returned an error *object*, `jq -e .` accepted it as valid JSON, and the stage fell through
|
||||||
|
to "no approving review" — **a permission failure wearing the costume of a missing override.**
|
||||||
|
`/pulls/{n}/files` and `/pulls/{n}/reviews` are under `read:repository`, which is exactly why every
|
||||||
|
other part of the same stage worked and this one did not.
|
||||||
|
|
||||||
|
Three fixes, and the first is the one that mattered most:
|
||||||
|
|
||||||
|
1. **The diagnosis is no longer misleading.** `COMMENTS_JSON` is out of the must-parse-or-block
|
||||||
|
loop and is explicitly checked for being an *array*; when it is not, the stage prints Gitea's
|
||||||
|
own `.message`, names the missing scope, and says where to post the override instead. An
|
||||||
|
unreadable source can only cause a false BLOCK, never a false pass, so degrading it to a loud
|
||||||
|
warning is safe — and blocking on it was never the honest behaviour.
|
||||||
|
2. **The override is accepted from a PR REVIEW body as well as a comment**, so the hatch is not
|
||||||
|
hostage to one API scope. A review of type "Comment" works on the token CI has *today*. The
|
||||||
|
gate names which source it used. `PENDING` (unsubmitted, author-only draft) reviews are dropped.
|
||||||
|
3. **`set-ci-token.sh` now requests `read:issue`**, which makes the ordinary comment box work —
|
||||||
|
the thing anybody reaches for first. It takes effect only on a **re-run**: an existing token's
|
||||||
|
scopes cannot be widened in place.
|
||||||
|
|
||||||
|
`ci/tests/approval-override.test.sh` grows from 23 to **38 assertions**, covering the reviews
|
||||||
|
source, `PENDING` reviews, an error *object* from the comments API, "an unreadable comments source
|
||||||
|
must not suppress a valid review override", latest-wins across both sources, and that the
|
||||||
|
diagnostic actually quotes Gitea's message.
|
||||||
|
|
||||||
|
**One adjacent fix, deliberately in scope: `ci/tests/gate-decision.test.sh` was already broken.**
|
||||||
|
It hand-maintains a list of stub stage scripts that "must name EVERY stage `gate.sh` runs", and it
|
||||||
|
did not name `approval-policy` — added to `gate.sh` by PR #17 and never added here. `run_stage`
|
||||||
|
therefore got exit 127 from a missing file and recorded a FAILED stage in every case, so **15 of
|
||||||
|
50 assertions failed**, including every one expecting a PASS/UNDETERMINED headline. Measured on
|
||||||
|
pristine `origin/main` (`0c7e8dd`) before touching it, so the attribution is not a guess.
|
||||||
|
|
||||||
|
I fixed it because `make -C ci test` is the *only* runner for the suite this change adds — leaving
|
||||||
|
it red would have made the new tests unverifiable in practice. The file's own comment already
|
||||||
|
narrated this exact failure for `gitea-settings` and did not prevent the repeat, which says the
|
||||||
|
comment was not the fix. So the stub list is now **derived from `gate.sh`'s own `run_stage` lines**
|
||||||
|
and asserts it parsed at least 8 of them: a stage added to the gate can no longer be silently
|
||||||
|
missing from its test. `make -C ci test` goes from **FAILED to all suites passed**.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **The lock-out is real, and PR #35 is the proof.** Queried live: head sha
|
||||||
|
`8a094c46`, statuses `gate / gate (pull_request) = success :: Successful in 1m41s`,
|
||||||
|
`/pulls/35/reviews` → `[]`, `/branch_protections/main` → `required_approvals: 1`. I read the
|
||||||
|
**rule object**, not `/branches/main`, per §2b.
|
||||||
|
- **`jakman85` is the only collaborator**: `/collaborators` → one entry. Permission `owner`.
|
||||||
|
- **`gitea-admin` also reports `owner`** — this is why the agent-account consequence above is a
|
||||||
|
statement of fact and not a hypothetical.
|
||||||
|
- **PR #35 touches no sensitive paths** (`apps/wave-0/plex.yaml`, `apps/wave-1/{radarr,sonarr}.yaml`,
|
||||||
|
`catalog/{radarr,sonarr}.yaml`, its decisions entry) — so `ci/approval-policy.sh` was never its
|
||||||
|
blocker. Layer 1 alone unblocks it.
|
||||||
|
- **API shapes verified before depending on them**, rather than assumed: `/git/commits/{sha}`
|
||||||
|
returns `.commit.committer.date` as RFC3339 **with an offset** (`2026-10-01T23:09:08-05:00`),
|
||||||
|
not a trailing `Z` — a parser handling only `Z` would have silently dropped every real
|
||||||
|
override, so there is a test for the offset form. PR conversation comments are under
|
||||||
|
`/issues/{n}/comments`; `/pulls/{n}/comments` returns diff comments only and would never have
|
||||||
|
seen an override.
|
||||||
|
- **`ci/tests/approval-override.test.sh`: 23 passed, 0 failed.** Covers the happy path, bare
|
||||||
|
directive, mid-sentence and `>`-quoted directives, stale and equal-timestamp overrides,
|
||||||
|
unparseable timestamps, missing login, invalid JSON, non-list JSON, latest-wins, offset
|
||||||
|
timestamps, and a configurable directive.
|
||||||
|
- `bash -n` clean on `ci/approval-policy.sh`, `infra/gitea/allow-admin-self-merge.sh`,
|
||||||
|
`infra/gitea/require-gate.sh`; `jq -e .` clean on both JSON declarations.
|
||||||
|
- **`gate-decision.test.sh`'s 15 failures are pre-existing, measured not assumed.** Extracted
|
||||||
|
`origin/main`'s `ci/` at `0c7e8dd` into a throwaway directory and ran the suite from there:
|
||||||
|
**FAIL, 15 failed / 35 passed**. After deriving the stub list from `gate.sh`: **PASS, 50
|
||||||
|
assertions**.
|
||||||
|
- **`make -C ci test` in the pinned toolbox: `ci/tests: all suites passed`, rc 0** — approval-override
|
||||||
|
**38**, gate-decision 50, touched-requirements-base 31, test-attribution 15, touched-requirements 26,
|
||||||
|
decisions-log pass. It was rc 2 (`ci/tests: FAILED`) before the stub-list fix.
|
||||||
|
- **The live flip worked, and the drift detector proved it.** Run 202 (before): `gitea-settings:
|
||||||
|
FAIL`, `required_approvals: DECLARED 0 but LIVE is 1`. Run 203 (after the operator ran the
|
||||||
|
PATCH): `gitea-settings: pass`, with `block_admin_merge_override = true` among the keys it
|
||||||
|
checked. So the narrow change held — the approval count moved and the gate's teeth did not.
|
||||||
|
- **The first override attempt failed for a measured reason, not a guessed one.** Run 203 log:
|
||||||
|
`approval-override: COMMENTS_JSON is not a list`. Token scopes read from `set-ci-token.sh`:
|
||||||
|
`[read:repository, read:admin]`. The same run successfully read `/pulls/38` and
|
||||||
|
`/pulls/38/files` (author and sensitive paths both resolved) and got past the reviews
|
||||||
|
validation, which is how I know `/pulls/{n}/reviews` IS readable with that token — from
|
||||||
|
behaviour, not from documentation.
|
||||||
|
- **The comment itself was valid.** `created_at` 2026-10-02T05:31:24Z vs head commit `616a497`
|
||||||
|
committer date 2026-10-02T00:02:20-05:00 → the comment is 29m04s newer, so it passed the
|
||||||
|
staleness rule. Ruling the comment out is what left the token scope as the only candidate.
|
||||||
|
- **`?limit=1000` is not the culprit.** Queried `/issues/38/comments` with `limit=1000`, `limit=50`
|
||||||
|
and no limit using my own credentials: `type=array` in all three. So the difference is the
|
||||||
|
credential, not the query.
|
||||||
|
- **The root cause is confirmed by Gitea, in its own words.** Run 206 (the fix in place):
|
||||||
|
`[warn] cannot read PR COMMENTS from Gitea — token does not have at least one of required
|
||||||
|
scope(s), required=[read:issue], token scope=read:admin,read:repository.` followed by the
|
||||||
|
instruction to post the override as a review. That is the diagnosis the first attempt should
|
||||||
|
have produced, and `gitea-settings: pass` in the same run. The stage still ends `FAIL`, correctly
|
||||||
|
— there is no valid override on the PR yet.
|
||||||
|
- **Toolbox merge gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`), run on the
|
||||||
|
rebased branch: **rc 3, `MERGE GATE: UNDETERMINED`**. Green stages: spec-immutability,
|
||||||
|
decisions-log, lint, l0 (23 offline cases, 0 failures), render-golden including the new
|
||||||
|
`catalog/render.py --selftest`, l1 (3 cases), approval-policy (N/A offline). Skipped:
|
||||||
|
`gitea-settings` (no `SETTINGS_API_TOKEN` on this host) and `conformance` (no cluster).
|
||||||
|
**This is NOT a green run and I am not reporting it as one** — rc 3 is UNDETERMINED per the
|
||||||
|
CLAUDE.md §3 contract.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The override has still never been ACCEPTED by a real run.** This is the second attempt at
|
||||||
|
this bullet, which is itself the point: the first pass called the hatch unverified, and the
|
||||||
|
first real test found a defect that no amount of local testing would have (an API scope CI has
|
||||||
|
and I did not). What is now measured is that the parse → *reject* path works end to end and
|
||||||
|
diagnoses itself. The **accepting** branch — directive found → live permission lookup → exit 0 —
|
||||||
|
has never executed. Posting the override as a **review** on PR #38 is its first real test, and
|
||||||
|
I would not be surprised by a third round.
|
||||||
|
- **`read:issue` is the right scope name — Gitea said so itself, so this is no longer a guess.**
|
||||||
|
Run 206 printed Gitea's own message: `token does not have at least one of required scope(s),
|
||||||
|
required=[read:issue], token scope=read:admin,read:repository`. That both confirms the root
|
||||||
|
cause and names the string, which matters because this repo has been burned by a token *name*
|
||||||
|
that could not exist (`GITEA_SETTINGS_TOKEN`). What is **still** unverified is that the token
|
||||||
|
*mint* accepts it — `POST /users/{u}/tokens` with that scope has not been run. The review route
|
||||||
|
does not depend on it.
|
||||||
|
- **I did not verify that Gitea lets a PR's own author submit a `COMMENT`-type review.** Gitea
|
||||||
|
blocks author self-*approval*; "Comment" is expected to be allowed, and that expectation is now
|
||||||
|
load-bearing for the recommended route. It is one click to find out, and I deliberately did not
|
||||||
|
test it by posting an override myself: doing so as `gitea-admin` is precisely the agent
|
||||||
|
self-override that `CLAUDE.md` now forbids.
|
||||||
|
- **The whole re-run mechanism is manual.** `gate.yml` triggers on `pull_request` and `push: main`
|
||||||
|
only — there is no `issue_comment` trigger, so posting an override does **not** re-run the gate.
|
||||||
|
Re-run the job from the Actions UI, or close and reopen the PR (which fires `pull_request` and
|
||||||
|
leaves the head SHA alone). **Do not push an empty commit to re-trigger it:** that moves the head
|
||||||
|
commit and makes the override stale by the rule above, so it would have to be posted again. A
|
||||||
|
comment trigger was considered and not built — it would need to check out PR head code while the
|
||||||
|
job holds `SETTINGS_API_TOKEN`, which is the classic pwn-request shape and not worth it here.
|
||||||
|
- **I have not seen the gate still REFUSE a merge** now that approvals are 0. The claim that
|
||||||
|
`block_admin_merge_override: true` keeps a red gate unmergeable rests on Gitea's documented
|
||||||
|
behaviour and on the 2026-09-29 session, **not** on a provoked refusal in this one. That probe
|
||||||
|
is written out at the foot of `allow-admin-self-merge.sh` and should be done, exactly as the
|
||||||
|
push rejection was provoked rather than assumed.
|
||||||
|
- **Clock skew in the staleness check is unhandled.** Staleness compares the comment's
|
||||||
|
server-side `created_at` against the head commit's *committer* date, which comes from the
|
||||||
|
committer's machine. A commit dated in the future would reject legitimate overrides until real
|
||||||
|
time catches up (fail-closed, so loud, but annoying). Gitea's own `dismiss_stale_approvals`
|
||||||
|
compares SHAs instead; a comment cannot. Not hit here — PR #35's commit date is ~21 min behind
|
||||||
|
server time — and not fixed.
|
||||||
|
- **`ci/tests/approval-override.test.sh` does not run in the merge gate.** `ci/gate.sh` runs no
|
||||||
|
`ci/tests/*`; they run only via `make -C ci test`. So a regression in the parser would not be
|
||||||
|
caught by CI. PR #33 (merged while this branch was being written, and rebased onto here) fixed
|
||||||
|
the *same class* of gap for `catalog/render.py --selftest` by invoking it from
|
||||||
|
`ci/render-golden.sh` — it did **not** wire `ci/tests/*` in, which I confirmed after rebasing
|
||||||
|
rather than assuming. Wiring them is a one-line stage and I did not widen scope to it.
|
||||||
|
- **Authorisation itself is untested code.** `ci/approval-override.py` is unit-tested; the live
|
||||||
|
permission lookup, the `override_admins` allowlist branch, and every new BLOCK path in
|
||||||
|
`ci/approval-policy.sh` need Gitea and have never been exercised. The allowlist branch in
|
||||||
|
particular is implemented but **unused** — `override_admins` is absent by design.
|
||||||
|
- **The other open PRs will go red** once the live value changes, until they are rebased onto the
|
||||||
|
merged declaration: as of writing those are **#29** and **#37** (both `gitea-admin`), plus
|
||||||
|
**#35**. I did not rebase them. Merging them first avoids it entirely; the script's header gives
|
||||||
|
the order. PRs #33, #34 and #36 merged while this branch was being written — this branch is
|
||||||
|
rebased onto `0c7e8dd`, so the list was re-measured rather than carried forward.
|
||||||
|
- No cluster state was read or changed; nothing here touches Flux, Kyverno or any workload.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-01
|
||||||
|
branch: bugfix/arr-to-plex
|
||||||
|
title: Radarr and Sonarr may now reach Plex on 32400 — the network path only, not the Plex connection itself
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Radarr and Sonarr had no route to Plex. The media library lives on NFS
|
||||||
|
(`/mnt/Storage/plex`, the ZFS dataset `Storage/plex` on `${BULK_NFS_SERVER}`), and NFS
|
||||||
|
does not deliver filesystem CRUD notifications, so Plex never learns that an arr has
|
||||||
|
written a new file. Without a push from the arrs, the library only reflects new media
|
||||||
|
when somebody scans it by hand. That is the bug this branch addresses.
|
||||||
|
|
||||||
|
The fix is one `dependencies:` entry per app, added to the catalog — the source of truth:
|
||||||
|
|
||||||
|
- to: plex
|
||||||
|
port: 32400
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
in both `catalog/radarr.yaml` and `catalog/sonarr.yaml` (these two *are* the arr suite
|
||||||
|
here; `jackett`, `qbittorrent` and `seerr` are different roles and are untouched).
|
||||||
|
|
||||||
|
The renderer expands each entry into a **pair** of Cilium policies, which is why a
|
||||||
|
three-line catalog change produces 88 lines under `apps/`:
|
||||||
|
|
||||||
|
| Side | Policy | File |
|
||||||
|
|---|---|---|
|
||||||
|
| egress | `radarr-allow-plex-32400` | `apps/wave-1/radarr.yaml` |
|
||||||
|
| egress | `sonarr-allow-plex-32400` | `apps/wave-1/sonarr.yaml` |
|
||||||
|
| reciprocal ingress | `plex-allow-radarr-32400` | `apps/wave-0/plex.yaml` |
|
||||||
|
| reciprocal ingress | `plex-allow-sonarr-32400` | `apps/wave-0/plex.yaml` |
|
||||||
|
|
||||||
|
Three commits, and the middle two are worth recording because both were self-inflicted
|
||||||
|
gate failures rather than design changes:
|
||||||
|
|
||||||
|
- `753c48b` — the catalog edit alone, with `apps/` left unrendered.
|
||||||
|
- `9048c2f` — re-rendered `apps/` from the catalog, which `render==golden` requires.
|
||||||
|
- `58f02fe` — removed 20 trailing spaces on `catalog/radarr.yaml:103`, left behind as
|
||||||
|
comment-alignment padding when the comment moved down to the `port:` line. Sonarr's
|
||||||
|
equivalent line kept its comment in place, which is why only radarr tripped.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
**The lint failure, root-caused.** Gate run 193 on `9048c2f` failed with
|
||||||
|
`[fail] lint: FAILED -> blocks merge`, and lint was the only failing stage. yamllint
|
||||||
|
emitted 419 findings, but **418 were `line-length`**, which `ci/yamllint.yaml` pins at
|
||||||
|
`level: warning` and which therefore cannot change the exit code. The single error-level
|
||||||
|
finding in the entire repository was:
|
||||||
|
|
||||||
|
catalog/radarr.yaml
|
||||||
|
103:13 error trailing spaces (trailing-spaces)
|
||||||
|
|
||||||
|
`trailing-spaces` inherits the default severity (error), so that one line returned rc 1.
|
||||||
|
After the fix, `git grep -nIE ' +$' -- '*.yaml' '*.yml'` matches nothing repo-wide.
|
||||||
|
|
||||||
|
**Gate run 195 on `58f02fe`** (read from the job log, not from the status tick):
|
||||||
|
|
||||||
|
[ ok ] approval-policy: green [ ok ] l0: green
|
||||||
|
[ ok ] gitea-settings: green [ ok ] render-golden: green
|
||||||
|
[ ok ] decisions-log: green [ ok ] l1: green
|
||||||
|
[ ok ] lint: green
|
||||||
|
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||||
|
[fail] Stages that did not run: spec-immutability
|
||||||
|
|
||||||
|
The Gitea check reports `success` / "Successful in 1m41s", but the gate's own verdict is
|
||||||
|
**UNDETERMINED (rc 3)**, not PASS. That tick is `gate.yml` tolerating exit 3 for the one
|
||||||
|
stage it documents as unverifiable on this runner (the spec repo is not checked out
|
||||||
|
there). Nothing else produced a 3.
|
||||||
|
|
||||||
|
**`render==golden` green** on `58f02fe`, so `apps/` is a faithful canonical render of
|
||||||
|
`catalog/` — the four generated policies are not hand-written drift.
|
||||||
|
|
||||||
|
**DNS egress was already open** and is not a new gap: `apps/wave-1/radarr.yaml` carries
|
||||||
|
an existing allow for port 53 UDP+TCP, so resolving the Plex service name does not need
|
||||||
|
anything added here.
|
||||||
|
|
||||||
|
**Live branch protection on `main`**, measured against
|
||||||
|
`/api/v1/repos/Home-Network/home-network-k3s-impl/branches/main` rather than assumed:
|
||||||
|
`protected: true`, `user_can_push: false`, `enable_status_check: true`,
|
||||||
|
`status_check_contexts: ['gate / gate (pull_request)']`, `required_approvals: 1`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
**This change does not accomplish what the PR set out to do.** It opens the network path
|
||||||
|
and nothing else. Triggering a library update also requires each arr's
|
||||||
|
*Settings → Connect → Plex Media Server* entry plus a Plex token, and **none of that
|
||||||
|
exists anywhere in `catalog/` or `apps/`** — I grepped both for `plex`, `token` and
|
||||||
|
`connect` and found only NFS paths, a uid and a timezone. Radarr's and Sonarr's config
|
||||||
|
lives on a 5Gi Longhorn PVC mounted at `/config`, so that connection is UI-only state
|
||||||
|
that exists solely on the PVC and never in git, in the same way Crafty's local login
|
||||||
|
does. **Until somebody adds it in each app's UI, Plex still will not see library
|
||||||
|
updates, and this branch will look like it did nothing.** That follow-up is not tracked
|
||||||
|
by any file in this PR.
|
||||||
|
|
||||||
|
**L2/L3 conformance never ran.** `CLUSTER_AVAILABLE` is unset on the runner, so the
|
||||||
|
eight requirements this diff touches — `R-NET-01`, `R-NET-05`, `R-NET-06`, `R-NET-07`,
|
||||||
|
`R-IDP-02`, `R-IDP-06`, `R-GITOPS-02`, `R-PLAT-02` — are **UNVERIFIED**. That gap is
|
||||||
|
unusually load-bearing here: the entire substance of this change is four
|
||||||
|
CiliumNetworkPolicies, and no test has confirmed that traffic from the `radarr` or
|
||||||
|
`sonarr` namespaces actually reaches `plex:32400`. The green gate proves the offline
|
||||||
|
render subset only.
|
||||||
|
|
||||||
|
**`spec-immutability` did not run** (`R-TEST-06` not verified), for the documented
|
||||||
|
reason — the sibling spec repo is absent from the runner.
|
||||||
|
|
||||||
|
**Nothing was checked on the cluster.** I ran no `kubectl`, so I did not confirm that
|
||||||
|
the `plex` Service exposes 32400 as the arrs would address it, that the pod labels the
|
||||||
|
policies select (`app.kubernetes.io/name`, `k8s:io.kubernetes.pod.namespace`) match the
|
||||||
|
running pods, or that Cilium accepted the four new objects. Flux has not applied any of
|
||||||
|
this: the branch is unmerged, so `.status.lastAppliedRevision` does not contain it.
|
||||||
|
|
||||||
|
**I did not verify Plex's own side** — whether its library-update endpoint on 32400 is
|
||||||
|
the right target for the arrs' notification, or whether Plex requires
|
||||||
|
"Update my library automatically" enabled to act on one.
|
||||||
|
|
||||||
|
**I am not the approver and did not approve this PR.** `required_approvals: 1` is live,
|
||||||
|
and the only credential on the authoring workstation belongs to the PR's author
|
||||||
|
(`jakman85`), whose own review Gitea does not count. Per CLAUDE.md §1 the merge is a
|
||||||
|
human action regardless.
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-01
|
||||||
|
branch: feat/oauth2-proxy-api-route-per-app
|
||||||
|
title: The config model had no way to say "this app's API lives here" — so --api-route could not ship. Now it can, per app.
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Second half of PR #28 (`decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`), which
|
||||||
|
fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer
|
||||||
|
function and **deliberately declined `--api-route`** for a reason I have preserved rather than
|
||||||
|
worked around: that flag was measured on **one** app, and `PiHole serves no /api/ at all`, so a
|
||||||
|
renderer-wide constant would apply one app's path shape to five. The `auth` block is
|
||||||
|
`additionalProperties: false`, so there was no per-app override hook. **Creating the hook was the
|
||||||
|
work; setting the flag is the easy part.**
|
||||||
|
|
||||||
|
**Three surfaces, in order of what they protect:**
|
||||||
|
|
||||||
|
| Surface | Change | What it guards that the others cannot |
|
||||||
|
|---|---|---|
|
||||||
|
| `schemas/servicedefinition.schema.json` (**1.8.0 → 1.9.0**) | new optional `auth.apiRoutes`: array of anchored path regexes, `uniqueItems`, default `[]` | rejects a too-broad regex **where a human types it** — `ci/l0.sh` validates `catalog/*.yaml` against this schema on every gate run |
|
||||||
|
| `catalog/render.py` | emits one `--api-route=<r>` per declared entry, in order; **raises** on an unanchored entry | `ci/render-golden.sh` runs on every gate invocation even when the schema stage skips |
|
||||||
|
| `conformance/tests/l0-static/oauth2-proxy-api-routes-per-app/` (new) | cross-checks each rendered sidecar against its own ServiceDefinition | catches a **renderer constant**, which neither of the above can see |
|
||||||
|
|
||||||
|
**The `auth` block stays `additionalProperties: false`.** One named, documented key was added; the
|
||||||
|
door is not opened. That property is the reason the gap existed, and it is worth keeping — it is
|
||||||
|
what made "there is no override hook" a *fact* rather than a *guess* for the two engineers who hit
|
||||||
|
it before me.
|
||||||
|
|
||||||
|
**Rebased mid-flight.** `origin/main` advanced from `08deb20` to `cdf3e14` while I was working
|
||||||
|
(PR #27 and PR #32, the `hardware.gpu: intel-quicksync` capability). **That change also took schema
|
||||||
|
`1.8.0`**, so mine is renumbered to **1.9.0** and chained behind it; both version entries are
|
||||||
|
present in the description, and `catalog/render.py` had a two-sided conflict in the constants block
|
||||||
|
which is resolved by keeping both (the `DRI_*` constants and `OAUTH2_API_ROUTE_PATTERN` are
|
||||||
|
unrelated). Re-rendering after the rebase still changes **only** jackett and radarr — plex's new
|
||||||
|
golden reproduces byte-identically, so the two changes do not interact.
|
||||||
|
|
||||||
|
**Shape:** a **list** of regexes, not a single string, because Radarr needs two
|
||||||
|
(`^/api/` and `^/signalr/`) and `--api-route` is natively repeatable. A single string would have
|
||||||
|
forced alternation into the value, and alternation is exactly how breadth gets smuggled past a
|
||||||
|
prefix-only reading of a regex.
|
||||||
|
|
||||||
|
**The anchoring ratchet.** Each entry must match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$` — anchored at
|
||||||
|
`^/`, then literal path characters, with an optional trailing `.*` or `$`. The same pattern is in
|
||||||
|
the schema, in the renderer, and in the new check, and assertion (5) of the check **fails if they
|
||||||
|
drift apart**, because if they do the loosest one wins silently. Rejected shapes, all five
|
||||||
|
provoked: `^/`, `^/.*`, `.*`, `/api/` (unanchored), `^/api/|^/` (alternation). Alternation is
|
||||||
|
**refused rather than supported**: the flag is repeatable, so one entry per route costs nothing.
|
||||||
|
|
||||||
|
**Why breadth is the dangerous direction, and it is not symmetric.** A *missing* `--api-route` is a
|
||||||
|
silent 302 — bad, invisible, recoverable. A *too broad* one answers a plain **page load** with 401:
|
||||||
|
a logged-out operator gets a bare error page and is **never offered a sign-in**, so the service
|
||||||
|
looks permanently broken and no reload recovers it. That is a worse outcome than the defect the
|
||||||
|
flag fixes, which is why it is gated on three surfaces rather than documented in one.
|
||||||
|
|
||||||
|
### Also folded in: the selftest could exit 1 with ZERO diagnostics
|
||||||
|
|
||||||
|
Not my change originally — Desmond Oyelaran found it while wiring `catalog/render.py --selftest`
|
||||||
|
into the `render-golden` stage (his PR #33, which touches only `ci/render-golden.sh` and is **not**
|
||||||
|
in my base; `origin/main` at `cdf3e14` does not yet drive the selftest from any gate stage). The
|
||||||
|
coordinator asked me to fold the fix into this PR because the defect is in the file I own.
|
||||||
|
|
||||||
|
`_selftest()` reached `_cookie_name()` through a bare `next(genexpr)` with **no default**. Delete
|
||||||
|
the `--cookie-name` emission — exactly the mutation that assertion exists to catch — and it raises
|
||||||
|
`StopIteration`, which escapes `_selftest()` **before its collected `SELFTEST FAIL:` lines are
|
||||||
|
printed**. Right verdict, no diagnostics, and it masks every other assertion the same mutation
|
||||||
|
broke. Fixed with `next(..., None)` plus an explicit `check()` so a missing flag reports **as a
|
||||||
|
named assertion**.
|
||||||
|
|
||||||
|
Provoking it turned up **three more instances of the same failure class** in the same block, which
|
||||||
|
is why this is not literally a one-line change: `side = fa_containers[1]`,
|
||||||
|
`side["env"][0][...]`, and the `fa_svc` lookup all raise rather than assert when the sidecar is
|
||||||
|
absent entirely. All four are now None-tolerant, and the two fixtures I added for `--api-route`
|
||||||
|
were written the same way from the start. Measured, on throwaway copies:
|
||||||
|
|
||||||
|
| Mutation | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| `--cookie-name` emission deleted (origin/main's renderer) | rc 1, **0** `SELFTEST FAIL` lines, 1 `StopIteration` | rc 1, **3** `SELFTEST FAIL` lines, 0 tracebacks |
|
||||||
|
| the oauth2-proxy sidecar not appended at all | rc 1, **0** lines, `IndexError` | rc 1, **20** `SELFTEST FAIL` lines, 0 tracebacks |
|
||||||
|
|
||||||
|
Note the consequence of his stage change for anyone reading this later: once PR #33 lands,
|
||||||
|
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, so **rc 1 is not tolerated
|
||||||
|
in CI the way rc 3 is** — a selftest failure will block. Which is the right outcome, and it is why
|
||||||
|
a selftest that prints nothing was worth fixing before that lands rather than after.
|
||||||
|
|
||||||
|
### Declined, with reasons: the metrics-ingress NetworkPolicy
|
||||||
|
|
||||||
|
The coordinator asked for my judgement on bundling Ines Dubois's finding — that #28's metrics
|
||||||
|
listener genuinely serves (she measured `http=200`, 10,208 bytes,
|
||||||
|
`oauth2_proxy_requests_total{code="302"} 2` via port-forward) but the scrape will be **dropped**,
|
||||||
|
because namespace `jackett`'s default-deny has **no `ingress` key at all** and
|
||||||
|
`jackett-allow-gateway-ingress` admits `fromEntities: [ingress]` only. The fix belongs in each
|
||||||
|
app's own rendered policy, i.e. in my file and my goldens. **I declined to bundle it**, and asked
|
||||||
|
for it as the immediate next PR. Five reasons, in the order that decided it:
|
||||||
|
|
||||||
|
1. **The real reason is that it is a MODEL question, not a policy to bolt on.** Schema 1.6.0
|
||||||
|
already owns this: `observability.metricsEndpoint` renders the Service port **and** the
|
||||||
|
monitoring-namespace ingress allow. What the model cannot currently express is a **second**
|
||||||
|
metrics endpoint on the same pod belonging to a *sidecar the renderer injected*, rather than to
|
||||||
|
the workload the operator declared. Keying a bespoke policy off the pod label
|
||||||
|
`homelab.keithjackson.dev/auth-mode: forward-auth` works today, but it hardcodes "forward-auth
|
||||||
|
implies a metrics port on 44180" into a policy instead of **deriving** it from the sidecar
|
||||||
|
declaration that is already the single source of that port — the exact "list, don't derive"
|
||||||
|
anti-pattern `oauth2-proxy-cookie-names-distinct` assertion (5) exists to forbid, and which I
|
||||||
|
would be introducing one directory away from the check that forbids it. That deserves its own
|
||||||
|
review, and it is squarely the kind of decision I was brought in to make deliberately.
|
||||||
|
2. **Different subject, and it is a trust-boundary change.** Mine is the auth *response code* on
|
||||||
|
API paths (R-IDP-02). That is an ingress allow (R-NET-01/02, R-OBS-01) admitting a new
|
||||||
|
namespace to a new port. Bundling a security-relevant ingress rule into a PR whose headline is
|
||||||
|
`--api-route` is how such a rule gets waved through on the strength of the other half — and this
|
||||||
|
PR already needs a human for a *different* sensitive-path reason, so the second subject would be
|
||||||
|
reviewed in the shadow of the first.
|
||||||
|
3. **The conflict argument does not actually apply to my diff.** It assumes I am regenerating five
|
||||||
|
goldens; **I am regenerating two** (jackett, radarr), because the flag renders only where
|
||||||
|
declared. A metrics-ingress policy renders a *different document* — a CiliumNetworkPolicy — in
|
||||||
|
all five. Different hunks, in files git merges independently; and goldens regenerate
|
||||||
|
deterministically from the renderer, so the follow-up rebases onto `main` cleanly. The N-1
|
||||||
|
rebase problem `decisions/README.md` records was two branches appending to **the same hunk**,
|
||||||
|
which this is not. I checked my own diff before answering rather than accepting the premise.
|
||||||
|
4. **Every fact in the finding is hers and I have measured none of it** — not the 200/10,208 bytes,
|
||||||
|
not the `wget: download timed out` on `:44180` *and* `:4180`, not the Flux sibling ordering that
|
||||||
|
makes `oauth2-proxy` and `apps-wave-0` unordered. Shipping a security-adjacent rule on wholly
|
||||||
|
inherited evidence, as a footnote in someone else's PR, is precisely what CLAUDE.md §2b exists
|
||||||
|
to stop. In its own PR I would measure it first.
|
||||||
|
5. **Sequencing: the PodMonitor is in HER PR #29, not mine.** If I ship the policy and her
|
||||||
|
PodMonitor is reworked or delayed, I have opened an ingress allow for a scraper that selects
|
||||||
|
nothing — a hole with no consumer. The policy is only useful paired with the PodMonitor, which
|
||||||
|
argues for it landing adjacent to that, not here. It also drags in a
|
||||||
|
`KNOWN_EXTERNAL_MONITORS` entry in `conformance/tests/l0-static/servicemonitor-ports-resolve/`,
|
||||||
|
a second conformance interaction on top of the new check this PR already adds.
|
||||||
|
|
||||||
|
Diff size is the tiebreaker rather than the argument: **1,245 insertions across 13 files**,
|
||||||
|
including a ~400-line new conformance check. I would rather hand a reviewer one subject they can
|
||||||
|
actually falsify. **Queued, not dropped** — and if the next person takes it, the model note in (1)
|
||||||
|
is the part worth arguing about, not the YAML.
|
||||||
|
|
||||||
|
### Which apps got which routes, and the measurement behind each
|
||||||
|
|
||||||
|
| App | `auth.apiRoutes` | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| **jackett** | `^/api/` | 2026-09-30, its own access log: **33 × 302 against 2 × 401** on `/api/` paths, and **4 of 6 POSTs answered by the proxy and never reaching Jackett** (upstream field `-`, not `/`), including both `/api/v2.0/server/update` calls |
|
||||||
|
| **radarr** | `^/api/`, `^/signalr/` | `^/signalr/`: measured **by me, on the live post-fix pod** — see below. `^/api/`: measured on radarr by the PR #30 author, on the **pre-fix** pod |
|
||||||
|
| **pihole** | *(none — deliberate)* | serves no `/api/`; **0** unauthenticated requests in its current pod's 23 h |
|
||||||
|
| **sonarr** | *(none — not measured)* | 13,643 log lines, **0** unauthenticated requests. No traffic, not a negative result |
|
||||||
|
| **qbittorrent** | *(none — not measured)* | 13,649 log lines, **0** unauthenticated requests. Same |
|
||||||
|
|
||||||
|
**`^/signalr/` on radarr is the live proof this change was needed**, and it is the strongest
|
||||||
|
evidence in this entry. 23 h after PR #28 deployed, on `radarr-7d55cbffb9-42hmh` (0 restarts), the
|
||||||
|
sidecar logged **232 unauthenticated requests across the pod's entire lifetime, and all 232 are the
|
||||||
|
same call**: `POST /signalr/messages/negotiate`, browser UA, **302**, upstream `-` (Radarr never saw
|
||||||
|
one of them). That is a **left-open browser tab** whose session the cookie rename orphaned, polling
|
||||||
|
SignalR forever, silently redirected every time, and **never re-prompting the operator**. It is the
|
||||||
|
reported symptom — "reloading never fixes it and I am never re-prompted" — still happening in
|
||||||
|
production, on a service nobody was looking at. With 401 the UI's connection breaks **loudly**; the
|
||||||
|
operator reloads; `/` matches neither regex, so the reload signs them back in.
|
||||||
|
|
||||||
|
**`^/api/` on radarr: included, and here is the honest basis.** The current pod has logged **zero**
|
||||||
|
`/api/` traffic, so I did **not** re-measure it. It rests on a measurement of **radarr** recorded in
|
||||||
|
the merged PR #30 handoff — its 156 unauthenticated requests were `/api/v3/command` (62),
|
||||||
|
`/api/v3/queue/details` (46) and `/signalr/messages/negotiate` (43), i.e. **108 of 156 were
|
||||||
|
`/api/v3` XHRs**. That is this app's own evidence, by another engineer, not an analogy to another
|
||||||
|
app. I include it because `/api/v3/` is a pure JSON namespace a browser never navigates to (Radarr
|
||||||
|
serves its UI at `/`, assets under `/Content/` and `/MediaCover/`), so the breadth risk is nil and
|
||||||
|
the measured benefit is 70% of that app's rejection volume.
|
||||||
|
|
||||||
|
**Sonarr and qBittorrent were measured and deliberately left alone.** Sonarr is nearly the same
|
||||||
|
codebase as Radarr, which is exactly what makes `^/api/` + `^/signalr/` a tempting guess and still a
|
||||||
|
guess. I read both pods' logs: **zero** unauthenticated requests, nothing but health probes, because
|
||||||
|
nobody has opened either UI since the cookie fix rolled. **That is evidence of no traffic, not
|
||||||
|
evidence of no need.** Each catalog file now carries a comment saying so, with the numbers and what
|
||||||
|
to measure before adding the routes.
|
||||||
|
|
||||||
|
### Goldens and fixtures regenerated
|
||||||
|
|
||||||
|
Rendered with the canonical command
|
||||||
|
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`).
|
||||||
|
**Two goldens changed, not five** — and that is the per-app property proving itself on the real
|
||||||
|
tree rather than in a fixture:
|
||||||
|
|
||||||
|
apps/wave-0/jackett.yaml + --api-route=^/api/
|
||||||
|
apps/wave-1/radarr.yaml + --api-route=^/api/
|
||||||
|
+ --api-route=^/signalr/
|
||||||
|
|
||||||
|
`apps/wave-0/pihole.yaml`, `apps/wave-0/qbittorrent.yaml` and `apps/wave-1/sonarr.yaml` are
|
||||||
|
**byte-identical to `main`**. **No L1 fixture needed regenerating**: all three
|
||||||
|
`conformance/tests/l1-rendering/*/fixtures/svcdef*.yaml` declare `auth.mode: forward-auth` with no
|
||||||
|
`apiRoutes`, so they render no flag and their goldens are unchanged. PR #28 touched 5 goldens and 3
|
||||||
|
L1 fixtures because it changed flags on *every* sidecar; this one changes flags only where a
|
||||||
|
measurement says so, which is the whole point.
|
||||||
|
|
||||||
|
### The L0 decision (and it is a decision, not a default)
|
||||||
|
|
||||||
|
**Not** in `oauth2-proxy-cookie-names-distinct`. That check's subject is cookie **identity** — its
|
||||||
|
inputs are names and domains, its failure mode is mutual session eviction, its register is the
|
||||||
|
shared proxy's claim on the default name. `--api-route` shares none of those. Bundling them would
|
||||||
|
make that check's name a lie and couple two unrelated ratchets, so a future api-route decision could
|
||||||
|
only be expressed by weakening a *cookie* check. That is PR #28's own stated discipline, applied to
|
||||||
|
my change rather than quietly dropped.
|
||||||
|
|
||||||
|
**A sibling check, `oauth2-proxy-api-routes-per-app`** (whole-repo scope, so it runs on every gate
|
||||||
|
invocation via `ci/l0.sh`'s `--include-scope` union — no bespoke stage, which
|
||||||
|
`whole-repo-invariants-wired` would fail). **Nowhere was the wrong answer for exactly one reason:**
|
||||||
|
the existing gates cannot see a renderer constant. `render-golden` compares the render to the
|
||||||
|
goldens, so a constant **plus regenerated goldens agree with each other while both being wrong** —
|
||||||
|
I provoked that and it regenerates with **rc 0**. The new check's assertion (2) compares each
|
||||||
|
rendered sidecar against its **own ServiceDefinition**, which is the only reading that fails it.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **`--api-route` is SINGULAR, read off the PINNED binary rather than taken from the brief.**
|
||||||
|
`podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help`:
|
||||||
|
`--api-route strings return HTTP 401 instead of redirecting to authentication server if token
|
||||||
|
is not valid. Format: path_regex`. There is **no `--api-routes`** in the help output. The PM asked
|
||||||
|
me to verify rather than trust, and that is the result — the singular form is confirmed and the
|
||||||
|
help text also confirms the *semantics* I am relying on (401 instead of a redirect).
|
||||||
|
- **The premise, in-cluster, read-only, through the pinned toolbox, `--context k3s-prod` named
|
||||||
|
explicitly** (CLAUDE.md §2b and §5). PR #28 **is** deployed: all five sidecars carry
|
||||||
|
`--cookie-name=_oauth2_proxy_<app>`, `--cookie-domain=<app>.home.keithjackson.dev`,
|
||||||
|
`--cookie-refresh=2m30s` and `--metrics-address=0.0.0.0:44180`. Pods, all **0 restarts**:
|
||||||
|
`jackett-6877fcd9f5-ht9d9`, `pihole-…`, `qbittorrent-5547bdb9bd-zq557`,
|
||||||
|
`radarr-7d55cbffb9-42hmh`, `sonarr-ddf485775-8ckwt` (started 2026-09-30T21:41–21:42Z).
|
||||||
|
- **The full post-fix log census, parsed rather than grepped** (oauth2-proxy's access-log format,
|
||||||
|
with `/ping` health probes excluded), over each pod's whole life:
|
||||||
|
|
||||||
|
app lines unauth AuthSuccess removing session cookie sig invalid
|
||||||
|
jackett 13,649 0 0 0 0
|
||||||
|
pihole 13,649 0 0 0 0
|
||||||
|
qbittorrent 13,649 0 0 0 0
|
||||||
|
radarr 14,099 232 0 0 0
|
||||||
|
sonarr 13,643 0 0 0 0
|
||||||
|
|
||||||
|
Three findings, kept separate:
|
||||||
|
1. **The collision fix works.** Jackett was 39 unauthenticated before; it is **0** in 13,612
|
||||||
|
non-probe lines. The eviction fingerprint (`removing session` / `cookie signature not valid`)
|
||||||
|
is **0 on all five**, where radarr previously had 8 and 8.
|
||||||
|
2. **Radarr's 232 are one request.** 100% `POST /signalr/messages/negotiate`, 100% **302**, 100%
|
||||||
|
upstream `-`, browser UA (`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537…`). **Zero `/api/`
|
||||||
|
traffic of any kind** on this pod.
|
||||||
|
3. **0 `AuthSuccess` anywhere**, so nobody has signed in since the restart and `--cookie-refresh`
|
||||||
|
is **still** unexercised — unchanged from PR #28's own caveat.
|
||||||
|
- **Only two goldens change, and three are byte-identical to `main`.** `git status` after the
|
||||||
|
canonical render lists exactly `apps/wave-0/jackett.yaml` and `apps/wave-1/radarr.yaml`.
|
||||||
|
`render-golden` is green.
|
||||||
|
- **`catalog/render.py --selftest`: PASS**, with new assertions that (a) the `fa` fixture, which
|
||||||
|
declares nothing, renders **no** `--api-route`; (b) a fixture declaring two routes renders exactly
|
||||||
|
those two, in order; (c) the renderer **refuses** all five breadth shapes; (d) four legitimate
|
||||||
|
routes (`^/api/`, `^/signalr/`, `^/api/v3/.*`, `^/api/v2.0/server/update$`) are **accepted** — a
|
||||||
|
ratchet that blocks real API prefixes is a ratchet someone eventually deletes.
|
||||||
|
- **The new check: rc 0, 43 assertions, 6 sidecars audited**, with its inventory printed so the
|
||||||
|
reviewer can see what it read:
|
||||||
|
`jackett -> ['^/api/'] | pihole -> (none) | qbittorrent -> (none) | radarr -> ['^/api/',
|
||||||
|
'^/signalr/'] | sonarr -> (none) | crafty -> (none)`.
|
||||||
|
- **The check was tested in BOTH directions, on throwaway copies outside the repo**, never by
|
||||||
|
mutating the branch. Each mutation fired its own assertion class and nothing else:
|
||||||
|
- **Pre-change tree** (`git archive origin/main` + the test dropped in): **rc 1, 11 failures** —
|
||||||
|
no service declares routes, the renderer emits none, the renderer does not read `auth.apiRoutes`,
|
||||||
|
the pattern is absent from both renderer and schema, and the shipped renderer accepts all five
|
||||||
|
breadth shapes.
|
||||||
|
- **pihole's golden hand-edited** to carry a route it never declared: **rc 1, 2 failures** (the
|
||||||
|
catalog↔golden mismatch, and "declares nothing must render nothing").
|
||||||
|
- **radarr's golden widened** to `--api-route=^/`: **rc 1, 2 failures** (unanchored, plus the
|
||||||
|
mismatch).
|
||||||
|
- **Renderer turned into a constant and EVERY golden regenerated to match: the regen exits 0** —
|
||||||
|
i.e. `render-golden` is **happy** — and the new check exits **1 with 15 failures**, naming
|
||||||
|
pihole, qbittorrent, radarr and sonarr individually. **This is the case that justifies the
|
||||||
|
check's existence** and I provoked it rather than argued it.
|
||||||
|
- **Schema `items.pattern` deleted**: **rc 1, 1 failure** — the three-surface drift guard.
|
||||||
|
- **Unmutated control: rc 0.**
|
||||||
|
- **The selftest diagnostics fix was PROVOKED, not reasoned.** Both mutations above were run
|
||||||
|
against throwaway copies of the renderer outside the repo (`origin/main`'s `render.py` for the
|
||||||
|
"before" column, mine for the "after"), never by mutating the branch. The pre-fix StopIteration
|
||||||
|
and the post-fix 3-and-20 named assertions are quoted from those runs. `--selftest` on the
|
||||||
|
unmutated branch: **PASS**. Goldens after the fix: **unchanged** (it touches only `_selftest()`).
|
||||||
|
- **The check is selected by the whole-repo union.** `./conformance/run.sh --layer l0 --requirements
|
||||||
|
r-nosuchreq-99 --include-scope whole-repo --print-selection` lists
|
||||||
|
`tests/l0-static/oauth2-proxy-api-routes-per-app` among **21** tests (it was 20), i.e. it runs
|
||||||
|
even when the touched-requirement filter matches nothing.
|
||||||
|
- **THE SCHEMA GUARD IS PROVEN WITH THE PINNED VALIDATOR `ci/l0.sh` ACTUALLY DRIVES**, not by
|
||||||
|
reading the pattern. `./schemas/validate.sh` (`check-jsonschema, version 0.29.4`) against seven
|
||||||
|
throwaway ServiceDefinitions written outside the repo:
|
||||||
|
|
||||||
|
good ["^/api/", "^/signalr/"] rc 0
|
||||||
|
bad-root ["^/"] rc 1 apiRoutes ... does not match
|
||||||
|
bad-dotstar ["^/.*"] rc 1 apiRoutes ... does not match
|
||||||
|
bad-unanchored ["/api/"] rc 1 apiRoutes ... does not match
|
||||||
|
bad-alt ["^/api/|^/"] rc 1 apiRoutes ... does not match
|
||||||
|
bad-everything [".*"] rc 1 apiRoutes ... does not match
|
||||||
|
bad-mode ["^/api/"] on mode: oidc rc 1 const 'forward-auth'
|
||||||
|
|
||||||
|
So a too-broad regex is rejected **where a human types it**, on every gate run, by the real tool
|
||||||
|
— and the shape invariant fires too.
|
||||||
|
- **THE RENDERER'S REFUSAL REACHES `render-golden` AS A READABLE FAILURE, NOT A TRACEBACK.** With
|
||||||
|
`^/signalr/` swapped for `^/` in a *copy* of the catalog (outside the repo), both invocation
|
||||||
|
shapes exit **rc 1 with 0 tracebacks** — the single-SD form and `--all <dir> -o <dir>
|
||||||
|
--wave-layout --no-image-automation`, which is the one `ci/render-golden.sh` drives:
|
||||||
|
|
||||||
|
render.py: radarr: auth.apiRoutes entry '^/' is not an anchored path regex. Each entry
|
||||||
|
must start with '^/' and continue with literal path characters (e.g. '^/api/',
|
||||||
|
'^/signalr/', '^/api/v3/.*'). ... A too-broad --api-route answers a PAGE LOAD with 401, so
|
||||||
|
a logged-out operator sees a bare error page and is never offered a sign-in: the service
|
||||||
|
looks permanently broken rather than merely unauthenticated.
|
||||||
|
|
||||||
|
So the breadth guard is gated by `render-golden` even on a run where the schema stage skipped,
|
||||||
|
and the operator is told *why* rather than handed a stack trace.
|
||||||
|
- **The schema's new shape invariant.** A non-empty `auth.apiRoutes` requires
|
||||||
|
`auth.mode: forward-auth`, because the flag renders onto the per-app sidecar and no sidecar exists
|
||||||
|
for `oidc`/`native`/`none` — declaring it elsewhere would be a **silent no-op**, which is the
|
||||||
|
failure class this repo keeps paying for.
|
||||||
|
- **Gate:** toolbox `./ci/gate.sh` with the two-mount ENGINE shim — exit code and stage list in the
|
||||||
|
next section, because it is not a pass and that belongs there.
|
||||||
|
- **No mutating cluster access.** `kubectl get/logs/jsonpath` only, `--context k3s-prod` named on
|
||||||
|
every call. No `apply`/`edit`/`patch`, no `flux reconcile`. No Secret `.data` or `.stringData` was
|
||||||
|
read or printed; I did not need to touch a Secret at all this session. The one container I ran
|
||||||
|
locally was the pinned oauth2-proxy image, to read its `--help`.
|
||||||
|
- **`~/.git-credentials` was never read.** PR creation used `git credential fill`, which does not
|
||||||
|
echo the value.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **NO BROWSER ROUND-TRIP. BY ANYONE. FOR THIS FLAG.** This is the most important line here. The
|
||||||
|
302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access
|
||||||
|
logs; that a **401 repairs** the operator's experience is reasoned from HTTP semantics, not
|
||||||
|
watched. Nobody has added a Jackett indexer or recovered a Radarr tab through this
|
||||||
|
configuration. The human test below is the first real evidence this change will ever have.
|
||||||
|
- **Local gate: rc 3 (UNDETERMINED), not 0.** Green: `spec-immutability`, `decisions-log`, `lint`,
|
||||||
|
`l0`, `render-golden`, `l1`. **Skipped:** `gitea-settings` (no `SETTINGS_API_TOKEN` locally) and
|
||||||
|
**L2/L3 conformance** (`CLUSTER_AVAILABLE` unset). Nothing FAILED; nothing runtime was certified
|
||||||
|
either. **rc 3 is UNDETERMINED, never green.**
|
||||||
|
- **MY LOCAL `approval-policy: green` WAS MEANINGLESS, AND I ALMOST REPORTED IT AS EVIDENCE.**
|
||||||
|
`ci/approval-policy.sh:47` reads `GITHUB_EVENT_NAME`, which is unset outside CI, so the stage
|
||||||
|
short-circuits at line 50: `ok "not a pull_request event (event='none') — approval policy N/A"`.
|
||||||
|
**A clean local gate therefore CANNOT catch a sensitive-path problem — structurally, not by
|
||||||
|
accident.** Flagged to me by the coordinator from Ines Dubois's round, and I verified it by
|
||||||
|
re-running the gate with `GITHUB_EVENT_NAME=pull_request PR_NUMBER=34`: the stage then
|
||||||
|
**evaluates** and fails closed on the missing token —
|
||||||
|
|
||||||
|
[fail] no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews.
|
||||||
|
[fail] stage approval-policy: FAIL
|
||||||
|
[fail] approval-policy: a sensitive change lacks a non-author approval -> blocks merge
|
||||||
|
[fail] MERGE GATE: BLOCKED (rc 1, not 3)
|
||||||
|
|
||||||
|
— while **every other stage stays green** (`spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||||
|
`render-golden`, `l1`). That is the useful result: it bounds the CI failure to this one stage
|
||||||
|
rather than leaving "something is red" as the whole report. Fail-closed is correct here and
|
||||||
|
deliberate: unlike `gitea-settings`, where a missing credential is a lock-out, a skip of this
|
||||||
|
stage would be a bypass (CLAUDE.md §1).
|
||||||
|
- **CI, read from each commit's `/statuses` rather than from a tick.** Both contexts report, and
|
||||||
|
**only one of them runs**. Identical on `99560c2d`, `df1b25b6` and `f1d16f44`:
|
||||||
|
|
||||||
|
gate / gate (pull_request) state=failure desc='Failing after ~1m43-1m45s'
|
||||||
|
gate / conformance (pull_request) state=success desc='Has been skipped'
|
||||||
|
|
||||||
|
**That second line is §2b's trap in the wild**: a *skipped* Gitea job reports
|
||||||
|
`state=success`. The conformance job did not execute, so its green tick certifies nothing —
|
||||||
|
read the description, not the colour. The duration is given as a range on purpose: it differs
|
||||||
|
by a second or two per run, and pinning one number inside the file whose commit triggers the
|
||||||
|
next run is a self-reference, not a measurement. The load-bearing fields are the **state** and
|
||||||
|
the **description**, and those are stable across all three.
|
||||||
|
|
||||||
|
Worth recording that the **same context changed its answer over time**: on the first push it
|
||||||
|
briefly read `state=pending desc='Blocked by required conditions'` — still waiting on the gate
|
||||||
|
job it depends on — and resolved to `success` only once that job settled. So sampling a status
|
||||||
|
once can show either a pending **or** a green tick for a job that never executed.
|
||||||
|
**I could NOT read the CI job log to confirm the failing stage from CI's
|
||||||
|
own output**: this Gitea's `/api/v1/repos/{owner}/{repo}/actions/runs` endpoint answers
|
||||||
|
**404** (only `/actions/tasks` exists, which lists tasks without logs), and
|
||||||
|
`/actions/jobs/{task_id}/logs` answers `404 Not found` for the task id. So my attribution of the
|
||||||
|
CI failure to `approval-policy` rests on the local event-set reproduction above plus the
|
||||||
|
sensitive-path rule, **not** on CI's own log. That is weaker evidence than PR #28 had and I am
|
||||||
|
naming it rather than borrowing its wording.
|
||||||
|
- **Branch protection and the runner, RE-MEASURED this session against the live API** (§2b),
|
||||||
|
not inherited from CLAUDE.md §2's table:
|
||||||
|
|
||||||
|
GET /repos/Home-Network/home-network-k3s-impl/branches/main
|
||||||
|
protected True
|
||||||
|
user_can_push False
|
||||||
|
enable_status_check True
|
||||||
|
status_check_contexts ['gate / gate (pull_request)']
|
||||||
|
required_approvals 1
|
||||||
|
GET /admin/actions/runners
|
||||||
|
id=1 name=incluster-dind status=online
|
||||||
|
|
||||||
|
So `required_approvals=1` applies to **every** PR here regardless of path — this needs a human
|
||||||
|
even without the sensitive-path rule. I did **not** provoke a rejected push to `main` this
|
||||||
|
session; `user_can_push: False` is the API's word, which is exactly the weaker form of evidence
|
||||||
|
CLAUDE.md §2 warns about, and I did not strengthen it.
|
||||||
|
- **`^/api/` ON RADARR IS NOT MY MEASUREMENT.** It is PR #30's, taken on the **pre-fix** pod. The
|
||||||
|
current pod has logged **zero** `/api/` traffic in 23 h, so I could not reproduce it. I judge the
|
||||||
|
breadth risk nil (`/api/v3/` is pure JSON; the UI is at `/`), but if someone wants a strictly
|
||||||
|
self-measured configuration, drop `^/api/` and keep `^/signalr/` — the 232 are entirely SignalR.
|
||||||
|
- **SONARR AND QBITTORRENT ARE UNMEASURED, NOT CLEARED.** Zero traffic in 23 h is zero evidence.
|
||||||
|
qBittorrent's WebUI drives everything through `/api/v2/` and is *plausibly* affected the same way
|
||||||
|
Jackett is; Sonarr is nearly Radarr's codebase. **Both are left alone on purpose** — plausibility
|
||||||
|
is what put a renderer constant on the table in the first place. Expect to add `^/api/` to
|
||||||
|
qBittorrent and `^/api/` + `^/signalr/` to Sonarr once someone opens those UIs and records the
|
||||||
|
split. **This PR does not fix them.**
|
||||||
|
- **PiHole is asserted to serve no `/api/` on inherited evidence, not mine.** Its log shows 0
|
||||||
|
unauthenticated requests, which says nothing about what paths it serves. The claim comes from
|
||||||
|
PR #28/#30. Note the pinned `update.range` is `>=2024.0.0`, and PiHole v6 *does* introduce a REST
|
||||||
|
API at `/api/` — so if PiHole is ever upgraded across that line, this needs re-measuring. It is
|
||||||
|
still correct to declare nothing today, because declaring an unmeasured route is the mistake.
|
||||||
|
- **`--cookie-refresh=2m30s` has STILL never been exercised** — 0 `AuthSuccess` on all five pods.
|
||||||
|
PR #28 named this as the fix most likely to need a second pass and it remains unobserved. My
|
||||||
|
change does not touch it, but a sign-in during the human test below will exercise both at once,
|
||||||
|
so a failure there must be attributed carefully.
|
||||||
|
- **The regexes are not validated against the apps' real route tables.** I assert `/api/v3/` and
|
||||||
|
`/signalr/` are JSON/RPC namespaces a browser never navigates to from Radarr's and Jackett's
|
||||||
|
observed traffic and from how those UIs are built — **not** from reading their routing code or
|
||||||
|
enumerating their endpoints. A path I have not thought of that matches `^/api/` and *is*
|
||||||
|
browser-navigated would 401.
|
||||||
|
- **`OPTIONS` preflight and non-browser clients are unconsidered.** `--skip-auth-preflight` exists
|
||||||
|
and is not set; nothing in these logs shows a CORS preflight, and Sonarr/Radarr talk to Jackett
|
||||||
|
**directly on `:9117`**, bypassing the proxy entirely — so I do not expect impact. Unverified.
|
||||||
|
- **The live pods do not carry this change and nothing re-measures that they will.** git is not the
|
||||||
|
cluster (§2b). **Merging is what deploys**, and it will roll **jackett** and **radarr** only
|
||||||
|
(`strategy: Recreate` on RWO Longhorn volumes, so a brief gap per app, and radarr/sonarr lose
|
||||||
|
direct `:9117` access to Jackett while its pod restarts). Existing sessions survive — no cookie
|
||||||
|
flag changed — so unlike PR #28 nobody is signed out.
|
||||||
|
- **No L2/L3 assertion reads any of this.** There is no runtime check that a `/api/` request to a
|
||||||
|
gated app answers 401, and I did not write one; `CLUSTER_AVAILABLE` is unset so it could not run
|
||||||
|
in CI anyway. The whole guard is static.
|
||||||
|
- **`catalog/render.py --selftest` is UNGATED IN MY BASE, and I did not wire it.** Desmond's PR #33
|
||||||
|
does that (`ci/render-golden.sh`) and is in review, not merged — `origin/main` at `cdf3e14`
|
||||||
|
contains no stage that drives it, which I checked rather than assumed (`grep -n selftest
|
||||||
|
ci/render-golden.sh` → no match). **So until #33 lands, my new selftest assertions are
|
||||||
|
dev-time only**, and the *gated* half of my breadth ratchet is the schema `pattern` (via
|
||||||
|
`ci/l0.sh`'s schema stage), the renderer's own `raise` (via `render-golden`), and the new L0
|
||||||
|
check. I deliberately did not wire the selftest myself: #33 already does it, and duplicating it
|
||||||
|
here would collide with his branch in the one file he owns. Note the two PRs are **independent
|
||||||
|
but complementary** — his makes the stage real, mine makes its output readable when it fires.
|
||||||
|
- **I did NOT verify PR #33's own claims.** The 106-assertion count, the
|
||||||
|
selftest→stage→`gate.sh`→`BLOCKED` propagation chain, and the `--cookie-refresh 2m30s → 10m`
|
||||||
|
mutation being caught by the selftest but not by `render-golden` are all his measurements,
|
||||||
|
relayed to me by the coordinator. I reproduced only the StopIteration defect itself, because that
|
||||||
|
is the part I was asked to fix. If his branch is reworked, nothing in mine depends on it.
|
||||||
|
- **The new check does not read the spec repo** and asserts nothing about `../home-network-k3s`
|
||||||
|
(R-TEST-06, read-only). `schemas/renderer-contract.md` is updated in **this** repo only; the
|
||||||
|
canonical spec `docs/13-service-catalog.md` is **not** updated, because it is immutable from here.
|
||||||
|
Someone with spec write access should mirror `auth.apiRoutes` into docs/13 and docs/15.
|
||||||
|
- **THE OAUTH2-PROXY SIDECARS' METRICS ARE STILL UNSCRAPABLE, AND THIS PR DOES NOT FIX IT.** See
|
||||||
|
the declined-bundle section above. #28 shipped `--metrics-address` as a prerequisite and said
|
||||||
|
plainly that it was not scraped; Ines has now measured that the listener **serves** and that the
|
||||||
|
scrape is **dropped by each app namespace's default-deny**. Nothing in my change touches that.
|
||||||
|
Anyone reading this entry as "the fleet gates are observable now" is wrong, and the next PR is
|
||||||
|
where that changes.
|
||||||
|
- **Alternation is refused, and that is a judgement I may have to revisit.** No app in this catalog
|
||||||
|
needs it, and the flag is repeatable — but if some future app genuinely needs a regex the ratchet
|
||||||
|
rejects, the fix is to widen the pattern **with a reason** on all three surfaces at once, not to
|
||||||
|
delete the ratchet on one.
|
||||||
|
- **CI status: two contexts, and only one of them ran.** Quoted in the PR body and in the report to
|
||||||
|
the PM. `approval-policy` **fails by design** until a non-author approving review exists, because
|
||||||
|
the diff touches `conformance/**`, which `infra/gitea/approval-policy.json` lists as sensitive
|
||||||
|
("the tests and the harness; changing them changes what green MEANS"). That is CLAUDE.md §1
|
||||||
|
working, not a defect. `required_approvals=1` applies to every PR here regardless of path, so this
|
||||||
|
needs a human either way.
|
||||||
|
|
||||||
|
### The human test — and how to tell a too-broad regex from a working one
|
||||||
|
|
||||||
|
After merge, wait for **new** pod names at 2/2 in `jackett` and `radarr`.
|
||||||
|
|
||||||
|
1. **The breadth test FIRST, before anything else, from a signed-out browser** (private window).
|
||||||
|
Open `https://radarr.home.keithjackson.dev/` and `https://jackett.home.keithjackson.dev/UI/Dashboard`.
|
||||||
|
**Each must bounce through Keycloak and land on the app's UI.** If either shows a bare
|
||||||
|
`401 Unauthorized` instead of a sign-in, **the regex is too broad — stop and revert.** That is
|
||||||
|
the one failure this change can introduce, and it is the only test that can see it. A working
|
||||||
|
regex is invisible on a page load; a broken one is total and immediate.
|
||||||
|
2. **Jackett, the original report:** signed in, *+ Add indexer* → pick one → *Configure* → *Okay*.
|
||||||
|
**It must save.** Previously 4 of 6 POSTs never reached Jackett at all.
|
||||||
|
3. **Radarr, the live symptom:** open Radarr and leave the tab open. DevTools → Network, filter
|
||||||
|
`signalr`. Today those negotiate calls are **302** and the UI never complains. They must now be
|
||||||
|
**200** while signed in — and if the session is ever lost, **401**, with the UI showing a visible
|
||||||
|
connection error instead of polling silently forever.
|
||||||
|
4. **The loud-failure test:** in DevTools, delete the `_oauth2_proxy_radarr` cookie and let the tab
|
||||||
|
poll. The negotiate call must answer **401** and Radarr must *show* a problem. Then **reload** —
|
||||||
|
`/` matches neither regex, so the reload must sign you back in silently. That round trip is the
|
||||||
|
entire fix: fail loudly on the API, recover on the page load.
|
||||||
|
5. **The six-minute test (PR #28's open item, still open):** stay on a dashboard for >6 minutes,
|
||||||
|
then save something. That exercises `--cookie-refresh=2m30s` past the 300 s access-token cliff.
|
||||||
|
If it fails, suspect the realm's refresh/SSO-idle timeouts — not this change.
|
||||||
|
6. **If something still fails, it must now fail LOUDLY.** A silent success-looking no-op means the
|
||||||
|
change did not take: check the running pod's args for `--api-route` before assuming anything
|
||||||
|
else.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-01
|
||||||
|
branch: feat/quicksync-via-device-plugin
|
||||||
|
title: "`hardware.gpu: intel-quicksync` is now satisfied by `gpu.intel.com/i915`, not a `/dev/dri` hostPath — the hostPath was measured not to work (device-cgroup EPERM), and the swap took ZERO ServiceDefinition edits"
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The mechanism behind `hardware.gpu: intel-quicksync` was replaced. **No ServiceDefinition field
|
||||||
|
moved** — that was the whole design bet when this field was made an enum of capability classes
|
||||||
|
instead of a device path, and this is the change that collects on it.
|
||||||
|
|
||||||
|
**Why the old mechanism had to go.** The `/dev/dri` hostPath shipped on 2026-09-30 was measured
|
||||||
|
**non-functional** in the live Plex pod on k3s-prod-2 on 2026-10-01. The device node was *visible*
|
||||||
|
in the container — `crw-rw---- root video2 226,128 renderD128` — and could not be opened:
|
||||||
|
|
||||||
|
| attempt | result |
|
||||||
|
|---|---|
|
||||||
|
| `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
| `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
|
||||||
|
`EPERM` is the **device cgroup**, not `EACCES` from file permissions, proven three ways: root on the
|
||||||
|
**host** opens the same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc
|
||||||
|
`config.json` read `linux.resources.devices: [{allow: False, access: 'rwm'}]` with
|
||||||
|
`linux.devices: None` — one deny-all rule, no allow entry for major 226. A hostPath is a *filesystem*
|
||||||
|
operation: it binds the device node in and adds **no cgroup allow rule**, because Kubernetes cannot
|
||||||
|
infer one from a volume. `uid 0` failing matters: root does not bypass the cgroup device gate, so
|
||||||
|
"run it as root" was never going to work either, and `privileged: true` is unavailable (Kyverno
|
||||||
|
`restrict-host-namespaces` is Enforce on app namespaces).
|
||||||
|
|
||||||
|
**`catalog/render.py`** — retired `DRI_VOLUME_NAME` / `DRI_DEVICE_DIR` for `QUICKSYNC_RESOURCE`
|
||||||
|
(`gpu.intel.com/i915`). The `hardware.gpu == intel-quicksync` branch now emits exactly one thing on
|
||||||
|
the **app container only** (never a sidecar):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
gpu.intel.com/i915: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
and no volume, no volumeMount, no `securityContext`, no `nodeSelector`. `limits` rather than
|
||||||
|
`requests` is mandatory, not stylistic: Kubernetes mirrors `requests` from `limits` automatically and
|
||||||
|
a requests-only extended resource is invalid. This is the **only** app-container `resources` render
|
||||||
|
path in the file and it is deliberately narrow — no general CPU/memory resources feature was invented
|
||||||
|
along the way.
|
||||||
|
|
||||||
|
**Self-tests** — the three existing ones updated plus one new:
|
||||||
|
- the emitted `resources` dict is asserted **exactly** (`{"limits": {"gpu.intel.com/i915": 1}}`), so
|
||||||
|
a stray CPU/memory addition through this field fails loudly;
|
||||||
|
- the retired hostPath volume and `/dev/dri` mount are asserted **absent**;
|
||||||
|
- the no-`hardware` negative extended to cover `resources` (absence still renders nothing at all);
|
||||||
|
- the `podSecurityContext` pass-through still holds, and the "no synthesised `securityContext`"
|
||||||
|
assertion is kept verbatim;
|
||||||
|
- **new:** the i915 limit must never reach the oauth2-proxy sidecar.
|
||||||
|
|
||||||
|
**`schemas/renderer-contract.md`** — the mechanism half of `### hardware` and invariant 8 rewritten.
|
||||||
|
The intent half is unchanged and still correct. The `EPERM` measurement is recorded as the *reason*
|
||||||
|
the design is what it is, so a future reader cannot "simplify" it back without knowing it was tried
|
||||||
|
and measured. The `DirectoryOrCreate` rationale is kept, explicitly labelled historical.
|
||||||
|
|
||||||
|
**`catalog/plex.yaml`** — comment correction only; the `hardware:` field is byte-identical.
|
||||||
|
`git diff` on that file contains **no non-comment lines**. Its old comment restated the mechanism
|
||||||
|
("exactly two things — a `/dev/dri` hostPath volume…"), which is now false; it states intent and
|
||||||
|
points at the contract, and keeps the measured facts that remain true (device on all three nodes,
|
||||||
|
`i915` bound, the cont-init hook / `supplementalGroups` explanation).
|
||||||
|
|
||||||
|
**`runbooks/plex-quicksync.md`** — the diagnostic sequence now checks
|
||||||
|
`.status.allocatable["gpu.intel.com/i915"]` and the plugin DaemonSet instead of a hostPath; the
|
||||||
|
decisive open test is specified **as uid 1000 via `s6-setuidgid plex`**; the cgroup `EPERM` is
|
||||||
|
recast from a *risk* to the *reason for the design*; a new top-of-page "STOP HERE IF PLEX IS
|
||||||
|
`Pending`" section and a "Plex will not schedule" procedure carry the new failure mode and the escape
|
||||||
|
hatch. The manual-toggle section (`HardwareAcceleratedCodecs`, Settings → Transcoder, still not
|
||||||
|
settable by any env var) is unchanged.
|
||||||
|
|
||||||
|
**`apps/wave-0/plex.yaml`** — re-rendered: six hostPath lines removed, a three-line `resources` block
|
||||||
|
added. Nothing else in `apps/` changed.
|
||||||
|
|
||||||
|
### The one real regression, stated rather than sold
|
||||||
|
|
||||||
|
This trades failure modes, and the new one is **worse in blast radius**:
|
||||||
|
|
||||||
|
| | device/resource unavailable ⇒ |
|
||||||
|
|---|---|
|
||||||
|
| **Before** | `DirectoryOrCreate` mounted an empty dir; Plex ran and **silently software-transcoded**. Invisible, harmless to everything else. |
|
||||||
|
| **After** | Plex is **Unschedulable**. `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, so this **stalls the app tier**. |
|
||||||
|
|
||||||
|
It is **unavoidable**: an extended resource is a hard scheduling constraint, and it is also the only
|
||||||
|
thing that produces the cgroup allow rule. There is no graceful-degradation variant. Plex's
|
||||||
|
schedulability is now coupled to the `intel-gpu-plugin` DaemonSet — broken, removed, or merely not
|
||||||
|
yet reconciled, it takes the app tier with it. That is the price of hardware transcoding working at
|
||||||
|
all.
|
||||||
|
|
||||||
|
**Escape hatch — one field, no code change:** delete the `hardware:` block from `catalog/plex.yaml`
|
||||||
|
and re-render. Plex returns to software transcoding and the tier unblocks. It is in the contract, in
|
||||||
|
the runbook's "Plex will not schedule → remedy B" with exact commands, and here, because it is what
|
||||||
|
someone will need at 2am.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
**The precondition, re-read from the branch rather than assumed:** the plugin is a standalone
|
||||||
|
DaemonSet at `platform/intel-gpu-plugin/` with its own Flux Kustomization
|
||||||
|
(`clusters/base/intel-gpu-plugin.yaml`, named `intel-gpu-plugin` in `flux-system`), namespace
|
||||||
|
`intel-gpu-plugin`, DaemonSet `intel-gpu-plugin`, pinned `intel/intel-gpu-plugin:0.37.0`. Every
|
||||||
|
`kubectl` command I wrote into the runbook names those exact objects. The node/DaemonSet readings
|
||||||
|
themselves (`i915=1` on all three nodes; 3 desired / 3 ready / 3 available) were **measured by the
|
||||||
|
PM, not by me** — see "What I did NOT verify".
|
||||||
|
|
||||||
|
**`render.py --selftest`: PASS**, and every new assertion is **mutation-tested — 8/8 mutants caught**
|
||||||
|
(a test that cannot fail is not a test):
|
||||||
|
|
||||||
|
| mutant | caught by |
|
||||||
|
|---|---|
|
||||||
|
| `requests` instead of `limits` | exact-resources assertion |
|
||||||
|
| emission dropped entirely | exact-resources assertion |
|
||||||
|
| `/dev/dri` hostPath + mount re-added | hostPath-absent assertion |
|
||||||
|
| emitted unconditionally (no `hardware` key) | no-`hardware` negative |
|
||||||
|
| `supplementalGroups` synthesised | no-pod-`securityContext` assertion |
|
||||||
|
| resource renamed `gpu.intel.com/xe` | exact-resources assertion |
|
||||||
|
| quantity as the string `"1"` | exact-resources assertion |
|
||||||
|
| limit leaked onto the oauth2-proxy sidecar | new app-container-only assertion |
|
||||||
|
|
||||||
|
**Re-render is clean and scoped.** `python3 catalog/render.py --all catalog -o apps --wave-layout
|
||||||
|
--no-image-automation` then `git status --porcelain apps/` ⇒ `M apps/wave-0/plex.yaml` and nothing
|
||||||
|
else. `git diff --stat apps/` ⇒ `1 file changed, 3 insertions(+), 6 deletions(-)` — exactly the six
|
||||||
|
hostPath lines out, the three-line `resources` block in. **`apps/wave-1/keycloak.yaml` and
|
||||||
|
`apps/wave-0/vm-demo.yaml` are untouched** (checked explicitly: they are git-tracked but excluded
|
||||||
|
from the golden comparison by `RENDER_GOLDEN_NOT_ONBOARDED` in `ci/render-golden.sh`, so a spurious
|
||||||
|
diff there would land ungated). `clusters/` is untouched; `--emit-waves` was not run.
|
||||||
|
|
||||||
|
**Invariant 8 holds, mechanically checked rather than eyeballed.** `git diff -U0 catalog/plex.yaml`
|
||||||
|
filtered to non-comment changed lines returns **empty**, and `yaml.safe_load` gives
|
||||||
|
`hardware == {'gpu': 'intel-quicksync'}`. Zero ServiceDefinition field edits.
|
||||||
|
|
||||||
|
**The gate: `rc 3` UNDETERMINED, which is this environment's calibrated best — NOT green, and I am
|
||||||
|
not calling it green.** Run from this worktree in the pinned toolbox
|
||||||
|
(`DEV_KUBECONFIG=/dev/null`, plus an `ENGINE` shim bind-mounting the main checkout's `.git`
|
||||||
|
read-only, per CLAUDE.md §3; `infra/ansible/.cache/prod.kubeconfig` was **not** copied):
|
||||||
|
|
||||||
|
| stage | result |
|
||||||
|
|---|---|
|
||||||
|
| `spec-immutability` | **SKIPPED** — spec repo not present at `/work/../home-network-k3s` (R-TEST-06 unverified) |
|
||||||
|
| `decisions-log` | green |
|
||||||
|
| `lint` | green |
|
||||||
|
| `l0` | green |
|
||||||
|
| `render-golden` | green |
|
||||||
|
| `l1` | green |
|
||||||
|
| `approval-policy` | green (`event='none'`, N/A off a PR event) |
|
||||||
|
| `gitea-settings` | **SKIPPED** — `SETTINGS_API_TOKEN` unset |
|
||||||
|
| `conformance` | **SKIPPED** — `CLUSTER_AVAILABLE != true`, L2/L3 not run |
|
||||||
|
|
||||||
|
Three skips, the three expected ones; **nothing failed**. `diff base: origin/main @ 8214642d87de …
|
||||||
|
HEAD @ 78bf68493d7d`, touched requirements resolved (`R-PLAT-02` among them), so the R-TEST-03
|
||||||
|
coverage gate actually applied — an earlier run on the uncommitted tree reported the touched set
|
||||||
|
"NOT DETERMINABLE (on-base)" and that is *not* the run I am quoting.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
**HARDWARE TRANSCODING IS STILL NOT OBSERVED WORKING, and nothing in this branch could establish
|
||||||
|
it.** A green gate proves the **manifest renders**, nothing more.
|
||||||
|
|
||||||
|
- **The `open()` test has not been re-run under the new mechanism, and cannot be until this merges
|
||||||
|
and Flux reconciles.** The whole case for this change is that the device plugin's
|
||||||
|
`DeviceSpec{… Permissions: "rw"}` produces the cgroup allow rule the hostPath could not. That is
|
||||||
|
read from the v0.37.0 **source**, not observed on this cluster. If the plugin does not in fact
|
||||||
|
deliver it here, this change buys nothing and costs schedulability.
|
||||||
|
- **The Plex-side toggle is still unset.** `HardwareAcceleratedCodecs` was **absent** from
|
||||||
|
`Preferences.xml` as of 2026-09-30 and nothing in this branch touches it; no env var, ConfigMap or
|
||||||
|
`postBuild` substitution can. It is a manual UI click, and until it happens Plex software-transcodes
|
||||||
|
no matter how the device arrives.
|
||||||
|
- **I did not measure the cluster at all.** `i915=1` on all three nodes and the DaemonSet's
|
||||||
|
3/3/3 come from the PM's brief, dated 2026-10-01. Per CLAUDE.md §2b this repo is not evidence about
|
||||||
|
the live platform, and neither is a handoff entry: **re-measure before relying on them** (the
|
||||||
|
commands are first-command #1 in the runbook). The DaemonSet was 2m15s old when read — young enough
|
||||||
|
that it had not been observed stable for any length of time.
|
||||||
|
- **The new failure mode has not been exercised.** "Plugin unavailable ⇒ Plex Unschedulable ⇒
|
||||||
|
`apps-wave-0` stalls ⇒ `apps-wave-1` blocked" is derived from `wait: true` and `dependsOn` in the
|
||||||
|
Flux wiring, not from a drill. The escape hatch (delete `hardware:`, re-render) is likewise
|
||||||
|
reasoned, not rehearsed — though it is a strict subset of the renderer path the selftest covers.
|
||||||
|
- **No L2/L3 conformance.** `CLUSTER_AVAILABLE` is unset, so the gate's runtime layer did not run
|
||||||
|
here and does not run in CI either. `spec-immutability` and `gitea-settings` also skipped.
|
||||||
|
- **CI's own tick, when it appears, will not distinguish rc 0 from rc 3**:
|
||||||
|
`.gitea/workflows/gate.yml:138` is `./ci/gate.sh || [ "$?" -eq 3 ]`. Read the stage output, not the
|
||||||
|
colour — and take the newest row per context from `GET /statuses/{sha}`, which appends.
|
||||||
|
- **`imagePullPolicy: Always` on `plexinc/pms-docker:latest` is still a live hazard** to the
|
||||||
|
cont-init-hook reasoning this feature leans on. Unchanged by this branch, recorded in the runbook's
|
||||||
|
floating-image caveat.
|
||||||
|
|
||||||
|
### Post-merge sequence for the operator (all mutating steps are yours, not an agent's)
|
||||||
|
|
||||||
|
1. Merge the PR once the gate reports. Confirm Flux applied it — compare
|
||||||
|
`kubectl --context k3s-prod -n flux-system get kustomization apps-wave-0
|
||||||
|
-o jsonpath='{.status.lastAppliedRevision}'` against the merge commit.
|
||||||
|
2. Confirm Plex **scheduled** (this is the new risk):
|
||||||
|
`kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide`.
|
||||||
|
`Pending` ⇒ runbook "Plex will not schedule".
|
||||||
|
3. Confirm it consumed the resource:
|
||||||
|
`kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex
|
||||||
|
-o jsonpath='{.items[0].spec.containers[0].resources.limits}'`.
|
||||||
|
4. **The decisive test — as uid 1000, via `s6-setuidgid plex`. `rc=0` is the pass condition:**
|
||||||
|
`kubectl --context k3s-prod -n plex exec deploy/plex -- s6-setuidgid plex sh -c
|
||||||
|
'exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED'`.
|
||||||
|
**Passing as `uid 0` is weaker evidence and must not be accepted as proof** — Plex runs as 1000,
|
||||||
|
and uid 0 was one of the two `EPERM`s that killed the hostPath.
|
||||||
|
5. **Only then** the Plex UI toggle: Settings → Transcoder → "Use hardware acceleration when
|
||||||
|
available" (under *Show Advanced*) → Save. Then force a transcode and read Dashboard → Activity:
|
||||||
|
`Transcode (hw)` is the proof; plain `Transcode` is software.
|
||||||
|
|
||||||
|
Step 4 failing means the plugin is not delivering the cgroup allow rule on this cluster. Do **not**
|
||||||
|
reach for `privileged: true` (Kyverno Enforce rejects it, and uid 0 failed anyway) and do **not**
|
||||||
|
re-add a `/dev/dri` hostPath (that is the mechanism this replaced, measured non-functional). The
|
||||||
|
escape hatch is to delete the `hardware:` block from `catalog/plex.yaml` and re-render.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-01
|
||||||
|
branch: fix/render-selftest-runs-in-gate
|
||||||
|
title: catalog/render.py's 106 assertions were dev-time only; the gate now runs them and blocks on them
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Single subject, one file: **`ci/render-golden.sh` now invokes `catalog/render.py --selftest`**
|
||||||
|
and exits 1 when it fails. Nothing else. No renderer change, no new conformance test, no new
|
||||||
|
gate stage.
|
||||||
|
|
||||||
|
This closes the top follow-up of `decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`:
|
||||||
|
|
||||||
|
> Nothing in `ci/` or `.gitea/` runs `catalog/render.py --selftest` — I grepped for it. So the
|
||||||
|
> twenty-odd assertions in that selftest, including the six I just added, are dev-time only: a
|
||||||
|
> future edit that drops either flag *and* re-renders the goldens would pass the entire merge
|
||||||
|
> gate.
|
||||||
|
|
||||||
|
Re-measured here and confirmed: before this commit, `grep -rl -- --selftest` over `ci/` and
|
||||||
|
`.gitea/` returned **nothing** for the renderer. The selftest executes **106** assertions
|
||||||
|
(`check()` invocations counted at runtime via `sys.settrace`, without editing `render.py`; 109
|
||||||
|
static call sites, three of them on failure-only branches). All 106 were unexercised by the
|
||||||
|
merge gate.
|
||||||
|
|
||||||
|
### The lead was WRONG, and here is why
|
||||||
|
|
||||||
|
The brief named `conformance/tests/l0-static/auditability-proofs-producible/check.sh:291` as the
|
||||||
|
one-line fix. That line is real and it does drive selftests:
|
||||||
|
|
||||||
|
for tool in ("record_run.py", "status.py"):
|
||||||
|
r = run([sys.executable, str(LIB / tool), "--selftest"])
|
||||||
|
|
||||||
|
But it is **the wrong hook**. That loop is section (d) of a check that serves **R-SEC-03**, and
|
||||||
|
its subject is the *evidence chain* — can a self-skipped run, or a stale ledger row, become an
|
||||||
|
audit proof? `record_run.py` and `status.py` are **in** that chain; `catalog/render.py` is not.
|
||||||
|
Hanging the renderer there would have made the check's name, its `intent` annotation and its
|
||||||
|
requirement mapping (`R-SEC-03`) all untrue, and a future decision about the renderer could then
|
||||||
|
only be expressed by weakening a *security-auditability* check. That is the precise coupling the
|
||||||
|
cookie-collision entry refused for its own check, and it applies here.
|
||||||
|
|
||||||
|
The second option that entry offered — a new scope-declaring L0 test — is the supported mechanism
|
||||||
|
for a **whole-repo invariant** (`ci/l0.sh` Stage 3b's `--include-scope whole-repo` union, ratcheted
|
||||||
|
by `tests/l0-static/whole-repo-invariants-wired`). It is the right shape for an invariant
|
||||||
|
quantified over the tracked tree. It is the wrong shape for *"a script in this repo must pass its
|
||||||
|
own unit test"*: it would need to declare requirements it does not serve, and it would be two new
|
||||||
|
files of several hundred lines by this tree's conventions for what is a six-line invocation.
|
||||||
|
|
||||||
|
**The honest home is `ci/render-golden.sh`** — the gate stage whose subject *already is*
|
||||||
|
`catalog/render.py`. It already resolves `$PYTHON` and `$RENDER_PY`, already reports rc 3 when
|
||||||
|
either is missing, already exits 1 on renderer error, and `ci/gate.sh` already maps it through
|
||||||
|
`for s in lint l0 render-golden l1` where a FAIL is BLOCKED and a SKIP is UNDETERMINED. The
|
||||||
|
stage's two assertions are independent and both are needed:
|
||||||
|
|
||||||
|
- `render-golden` catches golden-vs-renderer drift, but is satisfied by **any** renderer whose
|
||||||
|
output was re-rendered — including one that just lost a contract invariant and had all ten
|
||||||
|
goldens regenerated in the same commit. That is exactly the hole named in the quote above.
|
||||||
|
- `--selftest` catches that, and is blind to golden drift.
|
||||||
|
|
||||||
|
The file header and the stage name were updated so neither is a lie: the stage is now "the
|
||||||
|
renderer is trustworthy", with two numbered assertions, and the step summary emits its own
|
||||||
|
`- render-selftest:` line so the gate output still NAMES what ran.
|
||||||
|
|
||||||
|
The selftest runs **before** the empty-catalog `exit 3` on purpose: it renders synthetic
|
||||||
|
ServiceDefinitions in memory and is catalog-independent, so an empty catalog must not be able to
|
||||||
|
skip it.
|
||||||
|
|
||||||
|
`catalog/render.py` itself was **not touched** (owned by a concurrent branch this session), and
|
||||||
|
neither was any file under `conformance/`.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **The selftest runs and passes: rc 0, `render.py selftest: PASS`, 106 assertions executed.**
|
||||||
|
Counted at runtime, not from the source: a `sys.settrace` hook counting `check()` calls, in a
|
||||||
|
throwaway script outside the repo, with no edit to `render.py`.
|
||||||
|
- **The stage passes with it wired in**, host run of the single stage:
|
||||||
|
|
||||||
|
[gate] Stage: render==golden reproducibility (canonical render vs committed apps/)
|
||||||
|
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
|
||||||
|
[ ok ] render.py --selftest passed
|
||||||
|
[ ok ] render==golden: all 10 onboarded service golden(s) reproduce from the canonical render
|
||||||
|
|
||||||
|
rc **0**. The golden comparison still reports all 10 goldens, so the new invocation did not
|
||||||
|
displace what the stage already asserted.
|
||||||
|
- **IT CAN FAIL — provoked twice, against a mutated COPY of the renderer, never against the
|
||||||
|
tree.** `ci/render-golden.sh` already honours `RENDER_PY`, so each mutant lived in
|
||||||
|
`/tmp/claude-1000/desmond-selftest/` and the repo's `catalog/render.py` was never modified —
|
||||||
|
which matters, because that file belongs to a concurrent branch this session.
|
||||||
|
- **Mutation 1 — the regression this selftest was written for.** Deleted the
|
||||||
|
`f"--cookie-name=_oauth2_proxy_{s['name']}"` emission from `_oauth2_sidecar_container()`,
|
||||||
|
i.e. reverted the fleet-wide cookie-collision fix of PR #28. Stage rc **1**. Note the shape
|
||||||
|
of the failure, because it is a real (minor) weakness and is listed as a follow-up: the
|
||||||
|
selftest died with an uncaught `StopIteration` out of `next(a for a in container["args"] if
|
||||||
|
a.startswith("--cookie-name="))` at `render.py:2641`, rather than printing its collected
|
||||||
|
`SELFTEST FAIL:` lines. It is correctly non-zero and the stage correctly blocks, but the
|
||||||
|
operator gets a traceback instead of the assertion message.
|
||||||
|
- **Mutation 2 — a clean, named assertion failure.** `OAUTH2_COOKIE_REFRESH = "2m30s"` ->
|
||||||
|
`"10m"`, widening the refresh past the measured 300s access-token lifetime. Stage rc **1**:
|
||||||
|
|
||||||
|
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
|
||||||
|
[fail] Every golden under apps/ is produced by this renderer, so the goldens below cannot
|
||||||
|
[fail] be trusted even if they reproduce byte-for-byte. Reproduce with:
|
||||||
|
[fail] python3 catalog/render.py --selftest
|
||||||
|
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). A
|
||||||
|
refresh at or past the access-token lifetime never renews in time, which is the same
|
||||||
|
logout it was added to stop
|
||||||
|
|
||||||
|
This is the mutation that proves the wiring's value most directly: mutation 2 would **not**
|
||||||
|
be caught by `render-golden`'s diff if the goldens were regenerated in the same commit, and
|
||||||
|
it is one of the two flags the cookie-collision entry explicitly recorded as **ungated**.
|
||||||
|
- **THE NON-ZERO PROPAGATES ALL THE WAY TO `gate.sh`'s rc — provoked, in the pinned toolbox, not
|
||||||
|
reasoned about.** `RENDER_PY=/desmond-selftest/render-mutant2.py ./ci/gate.sh`:
|
||||||
|
|
||||||
|
[ ok ] stage spec-immutability: pass
|
||||||
|
[ ok ] stage decisions-log: pass
|
||||||
|
[ ok ] stage lint: pass
|
||||||
|
[ ok ] stage l0: pass
|
||||||
|
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
|
||||||
|
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
|
||||||
|
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). ...
|
||||||
|
[fail] stage render-golden: FAIL
|
||||||
|
[ ok ] stage l1: pass
|
||||||
|
...
|
||||||
|
[fail] render-golden: FAILED -> blocks merge
|
||||||
|
[fail] MERGE GATE: BLOCKED
|
||||||
|
|
||||||
|
`gate.sh` rc **1**. Three links in the chain, each measured: selftest rc 1 -> stage rc 1 ->
|
||||||
|
gate rc 1 / `MERGE GATE: BLOCKED`. This mattered enough to provoke rather than assume: this
|
||||||
|
repo has a documented habit of exit codes getting flattened, and
|
||||||
|
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, which tolerates rc 3 —
|
||||||
|
so a stage that degraded a failure to a skip would be swallowed by CI. rc 1 is not tolerated
|
||||||
|
there, and rc 1 is what this produces.
|
||||||
|
- **`spec-immutability` genuinely RAN and passed, it did not skip.** The two-mount worktree shim
|
||||||
|
plus `-e SPEC_REPO=/home-network-k3s` (the fix recorded in the 2026-09-30 cookie entry) works:
|
||||||
|
`stage spec-immutability: pass`, not SKIPPED. R-TEST-06 is therefore actually covered by these
|
||||||
|
runs.
|
||||||
|
- **No collision with PR #29.** The brief warned that `feat/oauth2-proxy-auth-failure-visible`
|
||||||
|
had added checks under `conformance/tests/l0-static/`. Measured —
|
||||||
|
`git diff origin/main...origin/feat/oauth2-proxy-auth-failure-visible --name-only` — it touches
|
||||||
|
**no `conformance/` path at all**: `decisions/`, `platform/oauth2-proxy/` (a promtool test, an
|
||||||
|
alert rule, its kustomization), `runbooks/`. Disjoint from this branch either way, since this
|
||||||
|
branch touches only `ci/render-golden.sh` and this file.
|
||||||
|
- **Priya's files were not touched.** `catalog/render.py`, `schemas/servicedefinition.schema.json`,
|
||||||
|
`apps/wave-*` and `conformance/tests/l1-rendering/` are all untouched by this branch — confirmed
|
||||||
|
by `git diff --name-only`, which lists exactly two paths.
|
||||||
|
- **`catalog/render.py --selftest` was the LAST unwired selftest in the tree.** Measured:
|
||||||
|
`grep -rl -- --selftest` across the repo finds selftest-capable artifacts in exactly four
|
||||||
|
places, and after this commit every one is driven by the gate — `conformance/lib/record_run.py`
|
||||||
|
and `conformance/lib/status.py` by `auditability-proofs-producible/check.sh:291`, the Keycloak
|
||||||
|
client-secret reconciler (embedded in `platform/keycloak/client-secret-reconciler.yaml`) by
|
||||||
|
`keycloak-client-secret-reconciler/check.sh:633`, and `catalog/render.py` by this change.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Local gate: rc 3 (UNDETERMINED), NOT 0. This is not green and must not be reported as
|
||||||
|
green.** Measured in the pinned toolbox on `eeb6211`:
|
||||||
|
|
||||||
|
[ ok ] spec-immutability: green
|
||||||
|
[ ok ] approval-policy: green
|
||||||
|
[ ok ] decisions-log: green
|
||||||
|
[ ok ] lint: green
|
||||||
|
[ ok ] l0: green
|
||||||
|
[ ok ] render-golden: green
|
||||||
|
[ ok ] l1: green
|
||||||
|
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||||
|
[fail] Stages that did not run: gitea-settings
|
||||||
|
|
||||||
|
Nothing FAILED. The single cause is `gitea-settings` **SKIPPED** — `SETTINGS_API_TOKEN` is not
|
||||||
|
set in my environment, so branch protection and runner registration were not read; CI has that
|
||||||
|
token and the stage is expected to run there. **L2/L3 `conformance`** also skipped
|
||||||
|
(`CLUSTER_AVAILABLE` unset) but is a *declared* gap that `gate.sh` treats as not-applicable
|
||||||
|
rather than undetermined, so it is not what produced the 3. Touched requirements
|
||||||
|
**R-GITOPS-01 R-TEST-02 R-TEST-03 R-TEST-06** are therefore **UNVERIFIED at L2/L3**, all four.
|
||||||
|
The stage this branch changes, `render-golden`, is green **and** was independently proven able
|
||||||
|
to turn the whole gate red (above).
|
||||||
|
- **`approval-policy` passed LOCALLY only because there is no pull_request event**
|
||||||
|
(`not a pull_request event (event='none') — approval policy N/A`). That is not evidence that it
|
||||||
|
passes. This diff touches `ci/**`, a sensitive path, so in CI the stage is expected to **FAIL**
|
||||||
|
until a non-author approving review exists. That is §1 working, fail-closed, not a defect.
|
||||||
|
- **The selftest's own quality is unchanged and ungated.** I wired its *invocation*; I did not
|
||||||
|
review its 106 assertions, and nothing ratchets them. Deleting the body of `_selftest()` would
|
||||||
|
still pass this stage, because an empty selftest returns 0. The repo already has the right
|
||||||
|
answer for that — `conformance/tests/l0-static/keycloak-client-secret-reconciler/mutations.py`
|
||||||
|
injects regressions one at a time and requires `--selftest` to catch every one — and applying
|
||||||
|
that pattern to `catalog/render.py` is the follow-up, deliberately not done here: it is a
|
||||||
|
second subject, it touches `conformance/`, and it would need to live beside the renderer whose
|
||||||
|
file another branch owns this session. **Until it exists, "the selftest runs" is proven and
|
||||||
|
"the selftest is falsifiable" rests on my two hand-run mutations, which are not in the gate.**
|
||||||
|
- **Mutation 1's traceback is not fixed.** `render.py:2641`'s `next(...)` has no default, so the
|
||||||
|
absence of `--cookie-name` raises `StopIteration` and the selftest's collected `SELFTEST FAIL:`
|
||||||
|
lines are never printed. Correct verdict, poor diagnostics, and it suppresses the other
|
||||||
|
assertions that mutation also broke. The one-line fix (`next(..., None)` plus a `check()`) is in
|
||||||
|
`catalog/render.py`, which is another branch's file this session, so it is a follow-up and not
|
||||||
|
a thing I touched.
|
||||||
|
- **Nothing about the live cluster.** No `kubectl` of any kind was run on this branch — not even
|
||||||
|
read-only; this change is offline CI wiring and needed none. No `apply`/`edit`/`patch`, no
|
||||||
|
`flux reconcile`. Merging this deploys nothing: it changes only what the gate refuses.
|
||||||
|
- **CI's own run of this branch.** Quoted in the PR body from the commit's `/statuses`, with
|
||||||
|
descriptions read rather than colours. A *skipped* Gitea job reports `success` with
|
||||||
|
"Has been skipped" and certifies nothing.
|
||||||
|
- **`vars.CLUSTER_AVAILABLE` is still unset in CI**, so even a fully green PR proves the L0/L1
|
||||||
|
offline subset and nothing about runtime behaviour.
|
||||||
|
- **I did not re-measure branch protection, required approvals, or the runner.** Those are
|
||||||
|
CLAUDE.md §2 claims and §2b says re-measure before relying on them; this branch relies on none
|
||||||
|
of them beyond the PR flow itself, and I read the gate's own output rather than the API.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-02
|
||||||
|
branch: fix/plex-quicksync-verification
|
||||||
|
title: Hardware transcoding works — and `runbooks/plex-quicksync.md`'s verification section was wrong
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**Hardware transcoding is confirmed working on prod.** Measured 2026-10-02 from
|
||||||
|
`Plex Media Server.log` in the running pod: `vaapi` on `/dev/dri/renderD128`
|
||||||
|
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
||||||
|
`zero-copy support present`, `using zero-copy transcoding`, and
|
||||||
|
`final decoder: vaapi, final encoder: vaapi`. VAAPI for **both** directions, zero-copy, so frames
|
||||||
|
never round-trip through the CPU. The device-plugin mechanism from PR #37 works end to end.
|
||||||
|
|
||||||
|
**And `runbooks/plex-quicksync.md`'s verification section was wrong, which is the correction this
|
||||||
|
branch exists to publish.** The page said the Plex dashboard showing `Transcode (hw)` was "the *only*
|
||||||
|
proof available, and nothing else will ever tell you". The operator had hardware transcoding fully
|
||||||
|
working while **Dashboard → Activity showed nothing of the sort** — so the page's authoritative check
|
||||||
|
was silent on a working system, and sent someone looking for a fault that did not exist. Three
|
||||||
|
inference errors are now corrected on the page, two of them recorded as named false leads so the next
|
||||||
|
reader does not re-walk them:
|
||||||
|
|
||||||
|
1. **The dashboard is not proof.** Replaced as the primary check by Plex's own log — the grep is in
|
||||||
|
the page, in its **redacting** form, because `Plex Media Server.log` carries API URLs containing
|
||||||
|
`X-Plex-Token` (CLAUDE.md §6; a credential has been leaked into a transcript here before). The
|
||||||
|
page also now says explicitly that **`Plex Transcoder Statistics.log` is the wrong file** — it
|
||||||
|
holds XML `<Variant .../>` records, not ffmpeg output, and the name invites the mistake — and why
|
||||||
|
`grep -a` is required. The dashboard is **kept but demoted** to a secondary, best-effort signal,
|
||||||
|
with its absence stated to prove nothing.
|
||||||
|
2. **An fd snapshot is not evidence.** A `/proc/*/fd` sweep inside the pod found no holder of
|
||||||
|
`renderD128` while hardware transcoding was active; transcode sessions are short and the scan
|
||||||
|
landed in a gap. Recorded as a documented false lead, with the reliable process-level check in its
|
||||||
|
place: the transcoder's **argv** (`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128`), found via
|
||||||
|
`ps -eo pid,user,comm | grep -i transcoder`.
|
||||||
|
3. **A missing preference key does not mean the setting is off.** The page told readers to treat an
|
||||||
|
absent `HardwareAcceleratedCodecs` in `Preferences.xml` as "never enabled". With hardware
|
||||||
|
acceleration demonstrably on and working, that key was **absent from all 27 attributes** while
|
||||||
|
`HardwareDevicePath` **was** written and the log said `hardware transcoding: enabled`. First-command
|
||||||
|
#4 now reads both keys and states plainly that `HardwareDevicePath` present is a positive signal,
|
||||||
|
absence of `HardwareAcceleratedCodecs` means nothing either way, only `="0"` is conclusive, and the
|
||||||
|
log settles it.
|
||||||
|
|
||||||
|
Also on the page:
|
||||||
|
|
||||||
|
- **GID `992` is no longer presented as a number to expect.** Under the plugin the injected device
|
||||||
|
arrives `root:root`, so the image's `cont-init.d` hook resolves GID **`0`** and adds `plex` to the
|
||||||
|
**root group** — measured `Groups: 0 100 1000`, `id plex` → `groups=1000(plex),0(root),100(users)`.
|
||||||
|
992 was the hostPath-era host `render` GID. The page now says so, marks the root-group membership as
|
||||||
|
an inherent property of the plugin's `DeviceSpec` rather than a defect, and repeats that it is
|
||||||
|
**not** a reason to reintroduce `supplementalGroups` (still a no-op: `s6-setuidgid` rebuilds the
|
||||||
|
group list from `/etc/group`). The stale `Groups: 100 1000` claim in the escalation section is gone.
|
||||||
|
- **A new troubleshooting entry for legitimate per-title fallbacks**, marked as a **hypothesis**.
|
||||||
|
Between two fully hardware sessions, one logged `enabled, but no hardware decode accelerator found`
|
||||||
|
and empty final decoder/encoder. UHD 630 decodes H.264, HEVC 8/10-bit and VP9 but **not AV1** and
|
||||||
|
not 10-bit 4:2:2 HEVC, so the likely cause is the source file, not the platform — explicitly
|
||||||
|
written as a lead to check first, **not** a confirmed diagnosis of that item.
|
||||||
|
- The status is stated at the top of the page and in a rewritten provenance footer that separates the
|
||||||
|
2026-10-02 confirmations from the superseded 2026-09-30/2026-10-01 hostPath-era readings.
|
||||||
|
- `runbooks/README.md`'s index entry for the page was stale in the same two ways (it still described a
|
||||||
|
`/dev/dri` hostPath and the dashboard-only proof) and is corrected to match.
|
||||||
|
|
||||||
|
Unchanged on purpose: the "STOP HERE IF PLEX IS `Pending`" section and the escape hatch (delete the
|
||||||
|
`hardware:` block from `catalog/plex.yaml`, re-render) keep exactly their existing prominence — they
|
||||||
|
are correct and they are the 2am material. The "no alert is possible" reasoning is also kept verbatim
|
||||||
|
in substance: Plex declares no `metricsEndpoint`, renders no ServiceMonitor, and a fallback alert
|
||||||
|
would be a rule evaluating against nothing.
|
||||||
|
|
||||||
|
**Documentation only.** No change to `catalog/`, `schemas/`, `apps/` or `platform/`.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **Gate, in the pinned toolbox from this worktree** (`DEV_KUBECONFIG=/dev/null`, `ENGINE` shim
|
||||||
|
bind-mounting the main checkout's `.git`): **rc 3 UNDETERMINED**, diff base
|
||||||
|
`origin/main @ 948875aad37d`, with exactly the three expected skips and **no failures**. Green:
|
||||||
|
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy`. Skipped:
|
||||||
|
`spec-immutability` (spec repo unavailable → R-TEST-06 not verified), `gitea-settings`
|
||||||
|
(`SETTINGS_API_TOKEN` unset), `conformance` (`CLUSTER_AVAILABLE!=true` → L2/L3 not run).
|
||||||
|
Touched requirements: **R-OBS-04**, reported `UNVERIFIED` for lack of a cluster. `approval-policy`
|
||||||
|
passed as `N/A` locally (`event='none'`, not a `pull_request`) — it evaluates for real on the PR.
|
||||||
|
**rc 3 is not green**, it is the calibrated worktree result, and it is reported here as rc 3.
|
||||||
|
- **The gate was run three times, with the same rc 3 / three-skip result on each committed tip** —
|
||||||
|
including the final one. Worth recording from the first attempt: run *before* committing, with
|
||||||
|
`HEAD == origin/main`, the touched-requirement set is `NOT DETERMINABLE (on-base)` and the
|
||||||
|
R-TEST-03 coverage gate cannot be applied at all. Commit before gating from a fresh worktree branch.
|
||||||
|
- **`runbooks/kustomization.yaml` still carries `- plex-quicksync=plex-quicksync.md`** in the
|
||||||
|
`configMapGenerator` `files:` list (line 110) — checked explicitly, because without it the page
|
||||||
|
merges and still 404s. `README=README.md` is likewise present, which is what serves the corrected
|
||||||
|
index entry.
|
||||||
|
- **This change touches no `sensitive_paths`.** `infra/gitea/approval-policy.json` lists `ci/**`,
|
||||||
|
`.gitea/**`, `conformance/**`, `clusters/**`, `bootstrap/**`, `infra/gitea/**`,
|
||||||
|
`platform/kyverno/**`, `platform-config/kustomization.yaml`, `.sops.yaml`, `**/*.sops.yaml`,
|
||||||
|
`**/rbac.yaml`, `CLAUDE.md`. `runbooks/**` and `decisions/**` are not among them, and the
|
||||||
|
`approval-policy` stage passed.
|
||||||
|
- **No YAML was added or edited**, so `indent-sequences: consistent` (which blocked a sibling branch
|
||||||
|
today) is not in play here; `lint` passed over the full tracked YAML set regardless.
|
||||||
|
- **`DECISIONS-LOG.md` is untouched** — this handoff is its own file, per CLAUDE.md §7.
|
||||||
|
- **The PR's own CI run was read, not assumed** (PR #39, run 208, task 261, head `1677dff5688b`).
|
||||||
|
**It is RED, and not because of this branch.** Every stage this change could affect is green —
|
||||||
|
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` (`approval-policy` evaluated
|
||||||
|
for real on the `pull_request` event and passed: no sensitive path touched). The single failure is
|
||||||
|
**`gitea-settings`**, quoting the measured field:
|
||||||
|
|
||||||
|
[fail] required_approvals: DECLARED 1 but LIVE is 0
|
||||||
|
[fail] Gitea has DRIFTED from infra/gitea/expected-branch-protection.json.
|
||||||
|
[fail] MERGE GATE: BLOCKED
|
||||||
|
|
||||||
|
Every other declared setting matched (`block_admin_merge_override`, `block_on_rejected_reviews`,
|
||||||
|
`dismiss_stale_approvals`, `enable_status_check`, `enable_push=false`,
|
||||||
|
`status_check_contexts = ["gate / gate (pull_request)"]`, `runners online: 1`). So **live `main`
|
||||||
|
currently requires ZERO approvals**, the declaration still says 1, and with
|
||||||
|
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml` that drift **blocks every PR in
|
||||||
|
the repo**, not just this one. Note the shape of it: the check that would notice approvals are
|
||||||
|
unenforced is the check that is failing. Local branch `fix/admin-approval-override` (`616a497`,
|
||||||
|
"fix(ci): let an administrator merge their own green PR") looks like the in-flight work that means
|
||||||
|
to change the declaration. **I did not touch it** — `infra/gitea/**` is a sensitive path, needs a
|
||||||
|
non-author approval, and is outside this branch's documentation-only scope. It is an operator
|
||||||
|
decision: re-assert the protection, or change the declaration deliberately in its own PR.
|
||||||
|
- The repository-wide grep for `992` to find what else still asserts it: remaining occurrences are in
|
||||||
|
`catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md`,
|
||||||
|
`platform/intel-gpu-plugin/README.md`, `platform/intel-gpu-plugin/kustomization.yaml`, and three
|
||||||
|
older `decisions/` entries.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **I did not re-measure anything on the live cluster in this session.** Every platform number on the
|
||||||
|
page — the VAAPI/zero-copy log lines, `RW-OPEN-OK` as uid 1000, the `gpu.intel.com/i915` limits and
|
||||||
|
requests on the pod spec, `Groups: 0 100 1000`, `id plex`, the 27 `Preferences.xml` attributes, the
|
||||||
|
empty `/proc/*/fd` sweep — came to me as measurements taken earlier on 2026-10-02 and was
|
||||||
|
transcribed, not re-run. Per CLAUDE.md §2b, treat the page as reporting those readings rather than
|
||||||
|
as proof of current state; re-run the primary check before relying on it.
|
||||||
|
- **The per-title fallback cause is unverified and labelled as such.** The source codec of the title
|
||||||
|
that logged `no hardware decode accelerator found` was never read. The AV1 / 10-bit-4:2:2-HEVC
|
||||||
|
explanation is inferred from UHD 630's capability set alone.
|
||||||
|
- **Whether Plex's dashboard *ever* shows `Transcode (hw)` on this deployment is unknown.** It was
|
||||||
|
observed silent during working hardware transcoding; whether that is a client/version quirk, a
|
||||||
|
reporting bug, or something about this setup was not investigated. The page therefore demotes it
|
||||||
|
rather than explaining it.
|
||||||
|
- **The commands as written in the page were not executed from the page.** They are the forms that
|
||||||
|
were run, re-wrapped for `dev.sh`; in particular the `exec -it deploy/plex -- sh` route for the argv
|
||||||
|
check is noted as possibly lacking a TTY under `dev.sh run` and was not exercised in that shape.
|
||||||
|
- **Stale `992` copies outside `runbooks/` were left alone**, deliberately — scope was documentation
|
||||||
|
only, and `catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md` and
|
||||||
|
`platform/intel-gpu-plugin/` all still carry the hostPath-era `Groups: 44 100 992 1000` reading as
|
||||||
|
if current. `render.py --selftest` asserts around that comment, so correcting it is a real change
|
||||||
|
with gate consequences, not a doc edit. **That is open work.** Older `decisions/` entries also cite
|
||||||
|
992; those are historical records and should stay as written.
|
||||||
|
- **CI on the PR.** The branch is pushed and the PR opened; I did not watch the Gitea run report, so
|
||||||
|
nothing here should be read as "CI green". The only gate result I can quote is the local rc 3 above.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: fix/cilium-cgroup-hostroot
|
||||||
|
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
|
||||||
|
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
|
||||||
|
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
|
||||||
|
`autoMount` stays `false`.
|
||||||
|
|
||||||
|
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
|
||||||
|
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
|
||||||
|
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
|
||||||
|
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
|
||||||
|
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
|
||||||
|
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
|
||||||
|
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
|
||||||
|
agent.
|
||||||
|
|
||||||
|
**The consequence.** Every other host-netns process got no Service/LB translation:
|
||||||
|
- Service ClusterIPs were unreachable from every node's host.
|
||||||
|
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
|
||||||
|
Other nodes reached it only because their packets crossed the wire to the lease holder, where
|
||||||
|
the tc datapath handles them.
|
||||||
|
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
|
||||||
|
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
|
||||||
|
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
|
||||||
|
OIDC clients failed on whichever calls landed on that apiserver.
|
||||||
|
- Webhooks and aggregated APIs were unaffected only because k3s runs
|
||||||
|
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
|
||||||
|
|
||||||
|
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
|
||||||
|
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
|
||||||
|
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
|
||||||
|
host.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
|
||||||
|
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
|
||||||
|
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
|
||||||
|
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
|
||||||
|
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
|
||||||
|
`cgroup2`.
|
||||||
|
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
|
||||||
|
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
|
||||||
|
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
|
||||||
|
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
|
||||||
|
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
|
||||||
|
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
|
||||||
|
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
|
||||||
|
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
|
||||||
|
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
|
||||||
|
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
|
||||||
|
change only makes Service/LB addresses reachable from the host where they currently fail.
|
||||||
|
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
|
||||||
|
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
|
||||||
|
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
|
||||||
|
(no `mount-cgroup`).
|
||||||
|
- Toolbox gate: see the PR description.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
|
||||||
|
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
|
||||||
|
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
|
||||||
|
`authenticator not initialized` with no k3s restart.
|
||||||
|
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
|
||||||
|
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
|
||||||
|
- An end-to-end kubectl OIDC login (needs a browser).
|
||||||
|
- No regression test was added. A natural one is an L2 conformance check that a host-netns
|
||||||
|
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
|
||||||
|
path. Left as a follow-up.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: fix/node-keycloak-hosts-pin
|
||||||
|
title: Pin the OIDC issuer to the Gateway VIP on prod nodes — the WAN answer does not hairpin
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Follow-up to PR #40 (`decisions/2026-10-03-public-dns-wan-target.md`). Public DNS for
|
||||||
|
`*.home.keithjackson.dev` now CNAMEs to the WAN IP. The prod nodes resolve through public DNS,
|
||||||
|
and the router does not hairpin, so the kube-apiserver could no longer reach its OIDC issuer
|
||||||
|
`keycloak.home.keithjackson.dev`.
|
||||||
|
|
||||||
|
- `roles/node_baseline`: a new `node_baseline_hosts_pins` list (default `[]`) and one
|
||||||
|
`blockinfile` task (tags `dns`, `hosts`). It writes the pins into `/etc/hosts` as a single
|
||||||
|
marked block. An empty list removes the block.
|
||||||
|
- `group_vars/k3s_prod/vars.yml`: pins `keycloak.{{ k3s_base_domain }}` → `192.168.2.20`, which
|
||||||
|
must stay equal to `GATEWAY_VIP` (parity comment added). It also replaces the stale "no
|
||||||
|
/etc/hosts pin is needed" paragraph with the measured reason the pin is now required.
|
||||||
|
|
||||||
|
Dev is unaffected (empty default).
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- After PR #40 reconciled (`platform` lastAppliedRevision `576e1ab`, Healthy=True), on
|
||||||
|
k3s-prod-2 and k3s-prod-3: `getent hosts keycloak.home.keithjackson.dev` →
|
||||||
|
`136.51.115.252 wan.home.keithjackson.dev`. `curl https://keycloak…/realms/homelab/.well-known/openid-configuration`
|
||||||
|
→ exit 60, `ssl_verify_result=18` (the router's self-signed cert, not the Gateway).
|
||||||
|
- `journalctl -u k3s` (last 20 min) showed no OIDC/JWKS errors yet. The apiserver still holds
|
||||||
|
the keys it cached before the flip, so the failure is latent until a k3s restart or key rotation.
|
||||||
|
- Neither node has cloud-init (`/etc/cloud` absent), so nothing rewrites `/etc/hosts` at boot.
|
||||||
|
- `--check --diff --tags hosts --limit k3s-prod-2,k3s-prod-3`: each node gets exactly the
|
||||||
|
three-line marked block `192.168.2.20 keycloak.home.keithjackson.dev`.
|
||||||
|
- Outside-in, PR #40 works: check-host.net probes from 3 external nodes got `200 OK` from
|
||||||
|
`https://plex.home.keithjackson.dev/identity` at `136.51.115.252`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **k3s-prod-1**: Ansible cannot reach it. SSH host key verification fails (`known_hosts:94`,
|
||||||
|
ED25519 `SHA256:zgUS8OeWznzCX9hlN7XdZ8iLNkU1G2loXjtJFfEUYpE`), which is consistent with
|
||||||
|
the 2026-09-11 SSD replacement and reinstall. The operator must confirm the fingerprint
|
||||||
|
before the pin can be applied there.
|
||||||
|
- The playbook has not been applied for real. It is an operator run after merge:
|
||||||
|
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`.
|
||||||
|
- An end-to-end kubectl OIDC login after the pin (needs a browser).
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: feat/plex-remote-access-l4
|
||||||
|
title: Plex remote access — raw tcp/32400 via a pinned LoadBalancer VIP, not the L7 Gateway
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Reported symptom: users cannot reach Plex from outside the LAN. **Two independent causes**, measured
|
||||||
|
rather than inferred:
|
||||||
|
|
||||||
|
1. **Plex advertised no remote connection at all.** Live `Preferences.xml` on prod:
|
||||||
|
`customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while
|
||||||
|
`PublishServerOnPlexOnlineKey="1"` — publishing was on and there was nothing reachable to publish.
|
||||||
|
Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.
|
||||||
|
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` refused;
|
||||||
|
`136.51.115.252:443` answered with a **self-signed router cert** (`C=ZZ, CN=%D`), not the
|
||||||
|
Gateway's LE wildcard. The Plex Service was `ClusterIP` only — no off-cluster path to 32400 by
|
||||||
|
any route.
|
||||||
|
|
||||||
|
This branch fixes the in-cluster half. **Remote access does not work until the operator also does
|
||||||
|
two manual steps** (`runbooks/plex-remote-access.md`): the WAN tcp/32400 port-forward, and ticking
|
||||||
|
"manually specify public port" in the Plex UI.
|
||||||
|
|
||||||
|
- **`apps/wave-0/plex-remote.yaml` (new, hand-written).** A `type=LoadBalancer` Service on tcp/32400
|
||||||
|
pinned to `${PLEX_L4_VIP}` via `lbipam.cilium.io/ips`, plus a `CiliumNetworkPolicy`
|
||||||
|
(`plex-allow-remote-32400`) admitting `world`/`cluster`/`host`/`remote-node` to 32400. Non-catalog
|
||||||
|
because `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has no field
|
||||||
|
for a LoadBalancer Service or a pinned VIP — the same reason `apps/wave-0/pihole-dns.yaml`,
|
||||||
|
`platform/crafty/service-l4.yaml` and `apps/vms/unifi/50-service-l4.yaml` are hand-written.
|
||||||
|
- **`catalog/render.py`:** an `EXTRA_UNITS` entry (`plex-remote`, anchored to `plex`, `above: False`)
|
||||||
|
so `--emit-waves` places the unit in plex's own wave.
|
||||||
|
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_L4_VIP` (dev default `192.168.1.57`), plus the
|
||||||
|
substitution inventory comment updated to name `plex-remote: PLEX_L4_VIP`.
|
||||||
|
- **`clusters/prod/kustomization.yaml`:** prod override `PLEX_L4_VIP: 192.168.2.24`.
|
||||||
|
- **`apps/wave-0/kustomization.yaml`:** `plex-remote.yaml` added to `resources`.
|
||||||
|
- **`runbooks/plex-remote-access.md` (new):** the two manual steps, the four-step verification, why
|
||||||
|
no alert is possible, and the two documented warts below.
|
||||||
|
|
||||||
|
**`ADVERTISE_IP` was deliberately NOT set.** Setting it to `https://plex.home.keithjackson.dev:443`
|
||||||
|
was the alternative — it needs no new WAN hole and the mechanism was verified working on an
|
||||||
|
already-claimed server. **The operator chose the native path (2026-10-03)** so Plex terminates its own
|
||||||
|
TLS (`*.plex.direct`) and streams bypass the Envoy L7 hop. `catalog/plex.yaml`'s deferral note for
|
||||||
|
`ADVERTISE_IP` remains accurate and untouched.
|
||||||
|
|
||||||
|
**`externalTrafficPolicy: Cluster`, with a known cost.** `Local` would preserve the client IP, but
|
||||||
|
Cilium's L2 announcer does not follow the backend pod — the wart that cost this LAN an 8-day
|
||||||
|
Minecraft outage (see `platform/crafty/service-l4.yaml`). Consequence accepted and documented: SNAT
|
||||||
|
puts the client in `192.168.0.0/16`, inside `ALLOWED_NETWORKS`, so **Plex classifies remote streams
|
||||||
|
as LAN**. That is a metering effect (remote bandwidth limits will not bind), not an authorization one.
|
||||||
|
|
||||||
|
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK
|
||||||
|
it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
|
||||||
|
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
|
||||||
|
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
All measurements against live prod, 2026-10-03, read-only.
|
||||||
|
|
||||||
|
- **Plex's advertised state**, the finding this branch rests on: `customConnections=<ABSENT>`,
|
||||||
|
`ManualPortMappingMode=<ABSENT>`, `ManualPortMappingPort=<ABSENT>`,
|
||||||
|
`PublishServerOnPlexOnlineKey="1"`, `allowedNetworks="10.0.0.0/8,192.168.0.0/16"`. Read by
|
||||||
|
attribute grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`).
|
||||||
|
- **The L7 Gateway path works and is NOT the problem.** Via the Gateway VIP,
|
||||||
|
`GET https://plex.home.keithjackson.dev/identity` → `HTTP 200`, `claimed="1"`, real
|
||||||
|
`machineIdentifier`. HTTPRoute `plex-32400`: `Accepted=True`, `ResolvedRefs=True`.
|
||||||
|
- **Plex enforces its own auth**, which is what makes an unfiltered WAN port defensible:
|
||||||
|
`/library/sections`, `/status/sessions`, `/:/prefs` all → **401** unauthenticated through the
|
||||||
|
Gateway.
|
||||||
|
- **The SNAT/allowedNetworks wart is real, not theoretical.** A tagged probe through the Gateway was
|
||||||
|
logged by Plex as `[10.0.2.214:36040 (Allowed Network (WAN))]` — the Envoy pod IP, classified as an
|
||||||
|
allowed network.
|
||||||
|
- **WAN state:** `136.51.115.252:32400` → connection refused. `136.51.115.252:443` → self-signed
|
||||||
|
cert `C=ZZ, ST=Somewhere, O=%D342e7e6b, CN=%D`. Public DNS resolves correctly:
|
||||||
|
`plex.home.keithjackson.dev` → CNAME `wan.home.keithjackson.dev` → `136.51.115.252`.
|
||||||
|
- **`192.168.2.24` is free in-cluster.** `CiliumLoadBalancerIPPool default-pool`: IPsTotal 11 /
|
||||||
|
IPsUsed 4 / IPsAvailable 7; the only allocations are `.20` (cilium-gateway-shared), `.21`
|
||||||
|
(unifi-l4-*), `.22` (crafty-l4-*), `.23` (pihole-dns-*). Not a node address (`.10/.11/.12`) and not
|
||||||
|
the kube-vip control-plane VIP (`.9`).
|
||||||
|
- **The manual-port-mapping step genuinely cannot be automated.** Grep of the running image's whole
|
||||||
|
init surface (`/etc/cont-init.d`, `/plex-common.sh`, `/usr/local/bin`) for `ManualPortMapping` and
|
||||||
|
`PLEX_PREFERENCE` → **NONE**.
|
||||||
|
- **`ADVERTISE_IP` would have worked on a claimed server** (recorded because the runbook's
|
||||||
|
"why not" rests on it): `setPref "customConnections"` at line 126 of `40-plex-first-run` sits
|
||||||
|
**outside** the `PLEX_CLAIM`/`PlexOnlineToken` guard, and the `/.firstRunComplete` early-exit
|
||||||
|
marker is on the **overlay** FS, not the `/config` PVC, so the script re-runs every pod start.
|
||||||
|
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||||
|
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||||
|
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
|
||||||
|
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
|
||||||
|
- **`render-golden` passing is the specific proof** that the `render.py` `EXTRA_UNITS` change, the new
|
||||||
|
hand-written unit and the edited wave kustomization are mutually consistent.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE ACTUAL FIX. Nothing in this branch has been proven to make remote access work** — it cannot
|
||||||
|
be, because both remaining steps are off-repo and manual (router forward, Plex UI toggle). This
|
||||||
|
branch removes two blockers and adds none; it does not demonstrate an outcome.
|
||||||
|
- **Outside-in reachability, at all.** Every probe above was taken from inside the LAN, so the
|
||||||
|
`:443` self-signed cert strictly proves only that there is **no NAT hairpin** — it does **not**
|
||||||
|
prove outside-in failure. A router cert on WAN:443 is suggestive (its management UI would shadow a
|
||||||
|
forward), but unproven. **This repo has no off-LAN vantage point.** Test 3 in the runbook
|
||||||
|
(app.plex.tv from a cellular connection) is the only honest check.
|
||||||
|
- **`192.168.2.24` was never ARP-probed off-cluster.** Only in-cluster pool accounting was checked.
|
||||||
|
`.23` was `arping`-probed before it was claimed; `.24` was not, so an off-cluster squatter would
|
||||||
|
not have been caught.
|
||||||
|
- **The LoadBalancer has never been reconciled.** `plex-l4-tcp` does not exist in prod yet, so
|
||||||
|
LB-IPAM has not actually assigned the pinned VIP, L2 announcement has not been observed for it, and
|
||||||
|
the new CiliumNetworkPolicy has never admitted a real packet. Whether `world`/`host`/`cluster`/
|
||||||
|
`remote-node` is the correct entity set **under this SNAT path** is reasoned from
|
||||||
|
`platform/crafty/networkpolicy.yaml`'s working game-port rule, **not measured**.
|
||||||
|
- **Whether Plex's remote path needs anything beyond the manual port tick.** If plex.tv still lists
|
||||||
|
no remote connection after both manual steps, this branch is necessary-but-insufficient.
|
||||||
|
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not
|
||||||
|
a verdict: this PR touches `clusters/**`, so CI will block it pending an override.
|
||||||
|
- **PRE-EXISTING DRIFT I FOUND AND DELIBERATELY LEFT ALONE — worth its own follow-up.** Running the
|
||||||
|
documented `python3 catalog/render.py --emit-waves` regenerates the wave kustomizations and wants to
|
||||||
|
add **`vm-demo.yaml`** to wave 0 and **`keycloak.yaml`** to wave 1. Both are listed as
|
||||||
|
intentionally not onboarded in `ci/render-golden.sh`
|
||||||
|
(`RENDER_GOLDEN_NOT_ONBOARDED="keycloak vm-demo"`); their manifests are tracked in `apps/` but
|
||||||
|
deliberately absent from the `resources` lists so Flux does not apply them. `--emit-waves` places
|
||||||
|
any file present on disk and has **no awareness of that exclusion**, while `render-golden.sh` uses
|
||||||
|
`--wave-layout` and compares rendered manifests only — it never checks kustomization `resources`,
|
||||||
|
which is why this has survived unnoticed. **So the header "DO NOT hand-edit: re-run
|
||||||
|
`render.py --emit-waves`" is currently unsafe advice**: following it onboards an IdP and a KubeVirt
|
||||||
|
VM. I therefore restored both kustomizations and hand-added only my one line. I did not fix
|
||||||
|
`--emit-waves`, and I have not determined how long the drift has existed or whether either service
|
||||||
|
is *meant* to be onboarded.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-03
|
||||||
|
branch: feat/public-dns-wan-target
|
||||||
|
title: Public DNS now points at the WAN — Gateway-published names CNAME to an in-cluster DDNS record
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator closed the open "public-target gap" recorded in
|
||||||
|
`platform/externaldns/helmrelease.yaml`. The WAN NAT / port-forwarding (80/443 → Gateway VIP
|
||||||
|
192.168.2.20) was done by the operator outside this repo.
|
||||||
|
|
||||||
|
Operator decisions (2026-10-03):
|
||||||
|
|
||||||
|
1. **Scope: every Gateway-published name goes public.** That covers the five `exposure: public`
|
||||||
|
apps (plex, gitea, keycloak, nextcloud, seerr) and also the hand-authored admin UIs (auth,
|
||||||
|
crafty, grafana, headlamp, the `home.keithjackson.dev` apex). `internal` services still publish
|
||||||
|
nothing: their `controller: none` route annotation is checked before the target is read.
|
||||||
|
2. **The WAN IP is kept current in-cluster**, not hardcoded.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- `platform/gateway/gateway.yaml`: `external-dns.alpha.kubernetes.io/target: wan.${BASE_DOMAIN}` on
|
||||||
|
the shared Gateway. Every gateway-sourced record changes from `A 192.168.2.20` to
|
||||||
|
`CNAME wan.home.keithjackson.dev`.
|
||||||
|
- `platform/externaldns/ddns.yaml` (new): `favonia/cloudflare-ddns:1.17.1` Deployment in the
|
||||||
|
`external-dns` namespace. It reuses `cloudflare-api-token` and keeps `wan.${BASE_DOMAIN}` an
|
||||||
|
unproxied IPv4 A record at the WAN IP (detected via Cloudflare trace, checked every 5 min,
|
||||||
|
`DELETE_ON_STOP=false`). external-dns has no TXT registry entry for it, so it never owns or
|
||||||
|
deletes it.
|
||||||
|
- The helmrelease comment block and the README now describe the decision instead of the gap.
|
||||||
|
|
||||||
|
Known consequence: **k3s nodes resolve through public DNS** (`node_baseline_dns_servers` = 8.8.8.8 /
|
||||||
|
1.1.1.1). The kube-apiserver's OIDC issuer `keycloak.home.keithjackson.dev` therefore resolves to the
|
||||||
|
WAN IP from the nodes, and kubectl SSO works only if the router hairpins WAN:443 back to the Gateway.
|
||||||
|
A LAN-side probe of `136.51.115.252:443` on 2026-10-03 returned a **self-signed placeholder cert**,
|
||||||
|
not the Gateway's Let's Encrypt wildcard. That suggests no hairpin. The cert-based admin kubeconfig
|
||||||
|
is unaffected.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- Live prod, 2026-10-03, before the change: Gateway `gateway/shared` ADDRESS `192.168.2.20`.
|
||||||
|
External-dns args include `--source=gateway-httproute --source=service --policy=sync
|
||||||
|
--txt-owner-id=prod --domain-filter=keithjackson.dev`, and it logs "All records are already up to
|
||||||
|
date".
|
||||||
|
- Public answers at 1.1.1.1: plex/gitea/keycloak/grafana/auth/apex → `192.168.2.20`;
|
||||||
|
`crafty-mc` → `.22` and `unifi` → `.21` (service source, not affected by this change); jackett →
|
||||||
|
none (internal, correctly unpublished).
|
||||||
|
- The authority (betty.ns.cloudflare.com) has no unprefixed TXT, AAAA or MX at `plex.` or the apex,
|
||||||
|
so the A→CNAME switch has no co-existing record to collide with.
|
||||||
|
- WAN IP from the workstation: `136.51.115.252`.
|
||||||
|
- No CiliumNetworkPolicy or NetworkPolicy in `external-dns`. The Kyverno label and default-deny
|
||||||
|
policies match only `app-namespace` namespaces. The vpn egress policy selects only
|
||||||
|
`egress-class: vpn` pods.
|
||||||
|
- Toolbox gate result: see the PR description (run after this commit).
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The outside-in path.** I have no vantage point outside the LAN. Whether
|
||||||
|
`https://<svc>.home.keithjackson.dev` from the internet reaches the Gateway with the LE cert is
|
||||||
|
UNVERIFIED.
|
||||||
|
- **Hairpin / kubectl SSO after the flip.** Likely broken per the probe above. Fix candidates: a
|
||||||
|
node `/etc/hosts` pin for `keycloak.${BASE_DOMAIN}` → Gateway VIP via `node_baseline`, or router
|
||||||
|
NAT reflection.
|
||||||
|
- **The record-type flip itself**, until Flux reconciles: whether Cloudflare accepts external-dns's
|
||||||
|
create-CNAME in the same batch as the delete-A, or converges one interval later.
|
||||||
|
- `favonia/cloudflare-ddns` under this pod securityContext. The README warns that
|
||||||
|
`no-new-privileges` misbehaves on some Proxmox-style hosts. These are bare-metal nodes, but it is
|
||||||
|
untested until the pod runs.
|
||||||
|
- Whether the L4 service-source names (crafty-mc, unifi) should also go public. Not decided, not
|
||||||
|
changed.
|
||||||
|
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` header lines 39–40
|
||||||
|
still describe the target as open. That is a sensitive path, so it is left for a follow-up.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: feat/hubble-flow-export-plex
|
||||||
|
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
|
||||||
|
address connected to Plex. Not bandwidth metering, which does not matter here.
|
||||||
|
|
||||||
|
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
|
||||||
|
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
|
||||||
|
before forwarding to the backend pod:
|
||||||
|
|
||||||
|
| Measured 2026-10-04 | Value |
|
||||||
|
|---|---|
|
||||||
|
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
|
||||||
|
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
|
||||||
|
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
|
||||||
|
| `192.168.x` sources in Plex's log, ever | **0** |
|
||||||
|
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
|
||||||
|
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
|
||||||
|
|
||||||
|
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
|
||||||
|
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
|
||||||
|
because the backend pod is on a different node.
|
||||||
|
|
||||||
|
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
|
||||||
|
translation, so Hubble already holds the true source:
|
||||||
|
|
||||||
|
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
|
||||||
|
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
|
||||||
|
|
||||||
|
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
|
||||||
|
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
|
||||||
|
|
||||||
|
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
|
||||||
|
|
||||||
|
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
|
||||||
|
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
|
||||||
|
`{"source_port":["32400"]}`.
|
||||||
|
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||||
|
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||||
|
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
|
||||||
|
be a permanent Helm diff and an avoidable agent rollout.
|
||||||
|
|
||||||
|
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
|
||||||
|
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
|
||||||
|
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
|
||||||
|
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
|
||||||
|
`stdout` can be defined as `hubble-export-file-path`."* Query:
|
||||||
|
|
||||||
|
{namespace="kube-system", app="cilium"} |= "32400"
|
||||||
|
|
||||||
|
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
|
||||||
|
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
|
||||||
|
address did not match it, because at the point of observation the destination identity is not yet
|
||||||
|
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
|
||||||
|
exists to capture.
|
||||||
|
|
||||||
|
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
|
||||||
|
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
|
||||||
|
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
|
||||||
|
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
|
||||||
|
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
|
||||||
|
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
|
||||||
|
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
|
||||||
|
pinning both the pod and the announcement to one node and surrendering Plex's ability to
|
||||||
|
reschedule — a real availability loss to buy a log field. Same trap
|
||||||
|
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
|
||||||
|
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
|
||||||
|
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
|
||||||
|
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
|
||||||
|
logging.
|
||||||
|
|
||||||
|
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
|
||||||
|
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
|
||||||
|
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
|
||||||
|
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
|
||||||
|
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
|
||||||
|
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
|
||||||
|
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
|
||||||
|
only because metering is explicitly not a goal here.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, 2026-10-04, read-only.
|
||||||
|
|
||||||
|
- **Every measurement in the table above**, including the VIP-lease-to-node map
|
||||||
|
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
|
||||||
|
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
|
||||||
|
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
|
||||||
|
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
|
||||||
|
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
|
||||||
|
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
|
||||||
|
contains no `hubble-export-*` key at all.
|
||||||
|
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
|
||||||
|
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
|
||||||
|
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
|
||||||
|
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
|
||||||
|
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
|
||||||
|
the docs for the running version — not inferred from the crafty incident.
|
||||||
|
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
|
||||||
|
which is what rules DSR out.
|
||||||
|
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
|
||||||
|
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
|
||||||
|
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
|
||||||
|
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
|
||||||
|
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
|
||||||
|
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
|
||||||
|
stdout needs no Alloy change.
|
||||||
|
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
|
||||||
|
with `export.static` resolving to the intended object.
|
||||||
|
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||||
|
`render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two known environmental skips
|
||||||
|
(`gitea-settings` — no local `SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE`
|
||||||
|
unset). **Not green. Reported as rc 3.**
|
||||||
|
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
|
||||||
|
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
|
||||||
|
still has no `hubble-export-*` key. Every claim is about inputs.
|
||||||
|
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
|
||||||
|
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
|
||||||
|
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
|
||||||
|
and the chart default is a real filesystem path. If the literal string is not special-cased, the
|
||||||
|
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
|
||||||
|
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
|
||||||
|
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
|
||||||
|
`/var/run/cilium/hubble`.
|
||||||
|
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
|
||||||
|
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
|
||||||
|
but upstream gives **no port example** and I did not validate it against the running agent. A
|
||||||
|
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
|
||||||
|
nothing.
|
||||||
|
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
|
||||||
|
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
|
||||||
|
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
|
||||||
|
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
|
||||||
|
quiet moment.
|
||||||
|
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
|
||||||
|
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
|
||||||
|
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
|
||||||
|
by dropping a filter.
|
||||||
|
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
|
||||||
|
set actually matches (it depends on the cilium pods carrying
|
||||||
|
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
|
||||||
|
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
|
||||||
|
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
|
||||||
|
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
|
||||||
|
is not fed a post-translation view.
|
||||||
|
- **The parity convention has no automated check.** I kept all three files in sync by hand because
|
||||||
|
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
|
||||||
|
a future edit can still drift them silently.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: fix/oauth2-sidecar-metrics-scrape-netpol
|
||||||
|
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Added 2026-10-05, after merge:** the top item under *What I did NOT verify* below — "that the
|
||||||
|
> scrape now succeeds" — **has since been verified on prod**. All 6 targets read `up == 1`. The
|
||||||
|
> original text is left exactly as written; the measurements are in
|
||||||
|
> [[2026-10-05-oauth2-sidecar-scrape-verified]].
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
|
||||||
|
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
|
||||||
|
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
|
||||||
|
|
||||||
|
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
|
||||||
|
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
|
||||||
|
in at the call site and had **never emitted a single object on any branch** — correct, and
|
||||||
|
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
|
||||||
|
|
||||||
|
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
|
||||||
|
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
|
||||||
|
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
|
||||||
|
|
||||||
|
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
|
||||||
|
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
|
||||||
|
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
|
||||||
|
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
|
||||||
|
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
|
||||||
|
clause, so its packets were dropped.
|
||||||
|
|
||||||
|
Two deliberate choices, both recorded in the function's docstring:
|
||||||
|
|
||||||
|
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
|
||||||
|
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
|
||||||
|
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
|
||||||
|
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
|
||||||
|
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
|
||||||
|
function exists to prevent, moved one field over.
|
||||||
|
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
|
||||||
|
header records why that tier cannot host it: it would name app namespaces created by the
|
||||||
|
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
|
||||||
|
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
|
||||||
|
|
||||||
|
- **The cause, not a guess.** All 6 sidecar targets in pool
|
||||||
|
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
|
||||||
|
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
|
||||||
|
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
|
||||||
|
- **The listener is fine.** jackett's `oauth2-proxy` container carries
|
||||||
|
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
|
||||||
|
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
|
||||||
|
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
|
||||||
|
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
|
||||||
|
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
|
||||||
|
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
|
||||||
|
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
|
||||||
|
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
|
||||||
|
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
|
||||||
|
0 silences.
|
||||||
|
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
|
||||||
|
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
|
||||||
|
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
|
||||||
|
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
|
||||||
|
5 files changed, +21 lines each, nothing else touched.
|
||||||
|
- `render.py --selftest`: **PASS**.
|
||||||
|
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
|
||||||
|
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
|
||||||
|
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
|
||||||
|
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
|
||||||
|
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
|
||||||
|
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
|
||||||
|
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
|
||||||
|
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
|
||||||
|
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
|
||||||
|
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
|
||||||
|
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
|
||||||
|
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
|
||||||
|
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
|
||||||
|
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
|
||||||
|
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
|
||||||
|
render path was used, so no wave kustomization changed.
|
||||||
|
|
||||||
|
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
|
||||||
|
|
||||||
|
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
|
||||||
|
failing stage.** Read from the job log, not from the status tick:
|
||||||
|
|
||||||
|
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
|
||||||
|
[ ok ] stage decisions-log: pass
|
||||||
|
[ ok ] stage lint: pass
|
||||||
|
[ ok ] stage l0: pass
|
||||||
|
[ ok ] stage render-golden: pass
|
||||||
|
[ ok ] stage l1: pass
|
||||||
|
[fail] stage approval-policy: FAIL
|
||||||
|
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
|
||||||
|
[skip] stage conformance: SKIPPED (not run)
|
||||||
|
|
||||||
|
sensitive paths touched:
|
||||||
|
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
|
||||||
|
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
|
||||||
|
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
|
||||||
|
|
||||||
|
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
|
||||||
|
which is the independent confirmation that the regenerated goldens are right rather than merely
|
||||||
|
self-consistent with my local run.
|
||||||
|
|
||||||
|
One local-reproduction note worth recording, because it wasted a step: running
|
||||||
|
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
|
||||||
|
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
|
||||||
|
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
|
||||||
|
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
|
||||||
|
that 401 as the CI failure; the job log is what settled it.
|
||||||
|
|
||||||
|
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
|
||||||
|
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
|
||||||
|
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
|
||||||
|
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
|
||||||
|
offline it evaluates N/A.
|
||||||
|
|
||||||
|
The golden update is not optional — a renderer change that adds an object necessarily drifts the
|
||||||
|
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
|
||||||
|
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
|
||||||
|
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
|
||||||
|
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
|
||||||
|
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
|
||||||
|
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
|
||||||
|
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
|
||||||
|
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
|
||||||
|
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
|
||||||
|
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
|
||||||
|
which demonstrably works for 4180 — so this is inference from a working sibling, not a
|
||||||
|
measurement of the new object.
|
||||||
|
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
|
||||||
|
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
|
||||||
|
at all** — so there is no port to open and this change cannot help it. Separate work.
|
||||||
|
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
|
||||||
|
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
|
||||||
|
run here either — the gate proves the L0/L1 offline subset only).
|
||||||
|
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
|
||||||
|
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
|
||||||
|
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
|
||||||
|
I did not identify. It does not affect this fix.
|
||||||
|
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
|
||||||
|
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
|
||||||
|
from 2026-10-01.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: feat/pihole-declare-state
|
||||||
|
title: 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).** Secret `pihole-bootstrap`,
|
||||||
|
key `webui-password`. Named to the `<svc>-bootstrap-secret.sops.yaml` convention so
|
||||||
|
`render.py --emit-waves` places 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_password` sourced from that Secret.
|
||||||
|
`webserver.api.password` takes CLEARTEXT and FTL hashes it itself — verified in the running image,
|
||||||
|
where `pihole setpassword` *is* `setFTLConfigValue "webserver.api.password"`, commented upstream
|
||||||
|
"pihole-FTL will automatically hash the password". The PVC's `pwhash` becomes a derived artefact.
|
||||||
|
- **`catalog/pihole.yaml`:** a `lifecycle.postStart` hook that **reconciles** the declared adlist set
|
||||||
|
into `gravity.db` on every container start — adds missing, re-enables disabled, and **disables**
|
||||||
|
(never deletes) anything enabled but undeclared, running `pihole -g` only when something moved.
|
||||||
|
Same level-triggered idiom, and the same reasoning, as `catalog/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 `### CHANGED` markers, with
|
||||||
|
the password value masked. This is the measurement the whole branch rests on.
|
||||||
|
- **A fresh `gravity.db` seeds NO adlists.** The image's own schema template
|
||||||
|
`/etc/.pihole/advanced/Templates/gravity.db.sql` creates the `adlist` table and contains no
|
||||||
|
`INSERT INTO adlist` at 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.password` is a real settable key** — `pihole-FTL --config webserver.api.password`
|
||||||
|
returns a masked value, and `/usr/local/bin/pihole`'s `setpassword` sets exactly that key.
|
||||||
|
- **THE RENDERED HOOK ACTUALLY WORKS — all four paths, extracted from `apps/wave-0/pihole.yaml` and
|
||||||
|
run in the live pod against THROWAWAY COPIES of `gravity.db` in `/tmp`** (production `gravity.db`
|
||||||
|
was never written, and `pihole -g` was stubbed so no 70k-domain download could start):
|
||||||
|
1. *Steady state* → `already matches the declared set - nothing to do`. No writes, no rebuild.
|
||||||
|
2. *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.**
|
||||||
|
3. *An undeclared list enabled* → disabled it (`enabled=0`), left the declared one alone, and did
|
||||||
|
not delete the row.
|
||||||
|
4. *Declared list present but disabled* → re-enabled it.
|
||||||
|
- **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-password`
|
||||||
|
starts `ENC[`. The gate's `no-plaintext-secrets (R-SEC-01)` L0 check passes.
|
||||||
|
- **Schema:** `check-jsonschema` against `schemas/servicedefinition.schema.json` passes.
|
||||||
|
- **Render determinism:** the canonical
|
||||||
|
`render.py --all catalog -o apps --wave-layout --no-image-automation` changed **only**
|
||||||
|
`apps/wave-0/pihole.yaml`. `render-golden` passes, so the hook's text also satisfies
|
||||||
|
`assert_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-policy` all **pass**. The rc 3 is the two usual environmental
|
||||||
|
skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3
|
||||||
|
(`CLUSTER_AVAILABLE` unset). **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.password` on start, overwriting the existing `pwhash`. 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.** `postStart` must complete before the container
|
||||||
|
is reported Running. On a fresh PVC the hook waits for `gravity.db` (bounded, up to 180s) and then
|
||||||
|
runs a real `pihole -g` (72k domains, network downloads). `apps-wave-0` is `wait: true` and
|
||||||
|
`apps-wave-1` `dependsOn` it, 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 means `pihole -g`'s real duration here is unknown.
|
||||||
|
- **Whether `pihole -g` succeeds from inside the hook's environment.** Its failure is caught and the
|
||||||
|
existing `gravity.db` retained, 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 (currently
|
||||||
|
`additionalProperties: false` with no such field), `render.py` emitting a StatefulSet with
|
||||||
|
`volumeClaimTemplates` (the config PVC is **ReadWriteOnce**, so a 2-replica Deployment cannot
|
||||||
|
work), pod anti-affinity, a PodDisruptionBudget (there is none today), re-rendered goldens, and
|
||||||
|
`conformance/**` updates — the last making phase 2 sensitive-path as well. The PVC rename a
|
||||||
|
StatefulSet forces will discard the existing `gravity.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=300` plus 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 the `pihole-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 same `address=` lines is the only option that removes the
|
||||||
|
dependency, and it was considered and not chosen.
|
||||||
|
- **Port 80 on the `pihole` Service is still published** and still closed only by policy. Not narrowed.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: docs/pihole-ha-verified
|
||||||
|
title: PiHole HA is VERIFIED on live prod — including a failover drill with zero dropped DNS queries
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**No manifests changed. This entry exists to correct the log.** The three PiHole/Plex entries merged
|
||||||
|
earlier today each say, correctly at the time, that nothing had been reconciled and that the outcome
|
||||||
|
was unverified:
|
||||||
|
|
||||||
|
- `decisions/2026-10-04-pihole-declare-state.md` — "Nothing here has been reconciled."
|
||||||
|
- `decisions/2026-10-04-pihole-multi-replica.md` — "Nothing has been reconciled. No StatefulSet
|
||||||
|
exists, no second replica has ever run, the anti-affinity has never been evaluated..."
|
||||||
|
|
||||||
|
All three are now **merged, reconciled and measured**, so those sentences are stale in exactly the way
|
||||||
|
§2b of CLAUDE.md warns about — a reader who found them would conclude the work is unproven. This entry
|
||||||
|
is the current state; it does not edit the earlier files (separate files, nothing to merge).
|
||||||
|
|
||||||
|
Delivered and live as of 2026-10-04, from PRs #43 (plex tcp/32400), #44 (declare PiHole state) and
|
||||||
|
#45 (PiHole 2 replicas + the mountPath bug):
|
||||||
|
|
||||||
|
- PiHole is a **2-replica StatefulSet**, one pod per node, with per-replica Longhorn volumes, a
|
||||||
|
PodDisruptionBudget, and an `up` SLI that follows the rendered workload kind.
|
||||||
|
- PiHole's admin password and adlists are **declared in git** and reproduce onto a fresh volume.
|
||||||
|
- PiHole's config PVC is mounted where PiHole actually writes (`/etc/pihole`), which it was not before.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
All measured against live prod on 2026-10-04, after the merge of #45 (`dce20b7`).
|
||||||
|
|
||||||
|
**Reconcile.** `GitRepository` artifact `main@sha1:dce20b7`; `apps-wave-0`
|
||||||
|
`lastAppliedRevision=main@sha1:dce20b7`, `Ready=True/ReconciliationSucceeded`. It completed in roughly
|
||||||
|
**90 seconds**, well inside the Kustomization's `timeout: 5m` — the risk flagged in the multi-replica
|
||||||
|
entry ("whether `pihole -g` finishes inside the 5 m timeout") did **not** materialise. A transient
|
||||||
|
`Ready=False/DependencyNotReady: platform-config` seen mid-flight was the dependency cascade, not a
|
||||||
|
failure; `platform-config` was already `Ready=True` at the new revision moments later.
|
||||||
|
|
||||||
|
**Topology and objects.**
|
||||||
|
|
||||||
|
| Measured | Value |
|
||||||
|
|---|---|
|
||||||
|
| pods / nodes | `pihole-0` → **k3s-prod-3**, `pihole-1` → **k3s-prod-2** (2/2 Running each) — the required anti-affinity was satisfied |
|
||||||
|
| per-replica claims | `config-pihole-0`, `config-pihole-1` — both `Bound`, 2Gi |
|
||||||
|
| old `pihole-config` PVC | **pruned** (it held only `lost+found`, so nothing was lost) |
|
||||||
|
| old `pihole` Deployment | **pruned** — `No resources found in pihole namespace` |
|
||||||
|
| PodDisruptionBudget | `minAvailable 1`, `disruptionsAllowed 1`, `currentHealthy 2` |
|
||||||
|
|
||||||
|
**The mountPath bug is fixed.** `df` inside both pods reports `/etc/pihole` on
|
||||||
|
`/dev/longhorn/pvc-...` with **6.9 M used** — previously it was on `overlay` while the Longhorn volume
|
||||||
|
sat unused at `/data/config`. `backup.policy: daily-offcluster` now has real content to back up
|
||||||
|
instead of reporting success over an empty volume.
|
||||||
|
|
||||||
|
**Phase 1's declarations reproduce onto a FRESH volume — the point of doing it first.** Both replicas
|
||||||
|
booted with an empty `/etc/pihole`:
|
||||||
|
|
||||||
|
- **72,234 gravity domains** on each, rebuilt from the adlist declared in `catalog/pihole.yaml`.
|
||||||
|
- **The declared SOPS secret authenticates on both pods.** `POST /api/auth` using the container's own
|
||||||
|
`FTLCONF_webserver_api_password` returned `"valid":true` on `pihole-0` **and** `pihole-1` — so
|
||||||
|
Secret → env → FTL's own hashing → working login, identically on both. (The secret's value was never
|
||||||
|
printed; an earlier reading of `pwhash` as "unset" was a quoting bug in the check, disproven by an
|
||||||
|
empty password being rejected with `password incorrect` and then by this positive test.)
|
||||||
|
|
||||||
|
**User-facing behaviour through the VIP (192.168.2.23), from a LAN host.**
|
||||||
|
|
||||||
|
plex.home.keithjackson.dev -> 192.168.2.20 split-horizon intact
|
||||||
|
home.keithjackson.dev -> 192.168.2.20
|
||||||
|
unifi.home.keithjackson.dev -> 192.168.2.21
|
||||||
|
google.com -> 142.250.115.102 recursion to 8.8.8.8 works
|
||||||
|
doubleclick.net -> 0.0.0.0 blocking LIVE in FTL, not just in the DB
|
||||||
|
googleadservices.com -> 0.0.0.0
|
||||||
|
12 consecutive queries -> 12/12 answered
|
||||||
|
|
||||||
|
**THE FAILOVER DRILL — observed by the operator, 2026-10-04.** A single StatefulSet replica was
|
||||||
|
deleted. It was recreated in **~30 seconds**, and **DNS never dropped a request** throughout. This is
|
||||||
|
the first direct evidence that the replica count delivers what it was added for, rather than merely
|
||||||
|
rendering correctly.
|
||||||
|
|
||||||
|
**The SLI is not inert, and re-deriving it mattered.** Measured in Prometheus:
|
||||||
|
`kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}` returns **1 series,
|
||||||
|
value 2**; `kube_deployment_status_replicas_available{namespace="pihole"}` now returns **0 series**;
|
||||||
|
and Prometheus has `pihole-down` loaded as `state=inactive health=ok` with the StatefulSet expression.
|
||||||
|
Had the rule stayed Deployment-shaped, its `or absent(...)` branch would have matched the now-absent
|
||||||
|
series and **fired permanently** — a standing false page rather than silence. That is the concrete
|
||||||
|
payoff of defaulting `livenessWorkload.kind` from the rendered kind.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **NODE failure, as opposed to pod failure.** The drill deleted a pod, which does not exercise the
|
||||||
|
`tolerationSeconds: 300` path at all — that timer only applies when a node goes NotReady. The
|
||||||
|
mechanism protecting DNS is the same one just proven (the surviving replica plus
|
||||||
|
`externalTrafficPolicy: Cluster` on the DNS LoadBalancers), so user-visible impact should likewise be
|
||||||
|
nil; what the 300 s still delays is the *replacement* replica, and during that window there is no
|
||||||
|
redundancy left. Untested. Pulling power from a node is the only real test.
|
||||||
|
- **DNS during the cutover itself.** I was not sampling resolution while #45 reconciled, so whether
|
||||||
|
there was a brief gap between the Deployment being pruned and the StatefulSet becoming Ready is
|
||||||
|
**unmeasured**. The drill above covers the steady-state failover case, not that one-off transition.
|
||||||
|
- **Two-node loss.** The hard anti-affinity means `replicas: 2` needs two schedulable nodes; losing
|
||||||
|
two would leave a replica `Pending` and, with `wait: true`, stall the app tier. Reasoned, not
|
||||||
|
exercised — and it coincides with etcd quorum loss on a 3-node control plane.
|
||||||
|
- **Longhorn capacity headroom.** Two 2Gi claims at 3 replicas each is ~12Gi where there was ~6Gi.
|
||||||
|
Both claims are `Bound` and healthy; free space was not checked.
|
||||||
|
- **Query history is now split per replica** and the admin UI shows only whichever pod answered.
|
||||||
|
Adlists and password are identical, so blocking and auth are consistent; the *reporting* is not, and
|
||||||
|
whether that is acceptable long-term is the operator's call.
|
||||||
|
- **The residual risk is unchanged, and it is the larger one.** Replicas address node failure. LAN DNS
|
||||||
|
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||||
|
outside the cluster carrying the same `address=` lines removes that dependency. Considered and not
|
||||||
|
chosen (operator decision, 2026-10-04).
|
||||||
|
- **Plex remote access (#43) is NOT covered by this entry.** Its two manual steps (the WAN tcp/32400
|
||||||
|
forward and Plex's "manually specify public port") and the outside-in path remain as
|
||||||
|
`decisions/2026-10-03-plex-remote-access-l4.md` describes them. The operator reported it working;
|
||||||
|
I have not measured it from off-LAN.
|
||||||
|
- **`--emit-waves` drift is still open.** Re-running it still wants to onboard `vm-demo.yaml` and
|
||||||
|
`keycloak.yaml`, both listed as intentionally not onboarded in `ci/render-golden.sh`. Unfixed; see
|
||||||
|
`decisions/2026-10-03-plex-remote-access-l4.md`.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: feat/pihole-multi-replica
|
||||||
|
title: PiHole runs two replicas on different nodes — and its config PVC was mounted where PiHole never writes
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Phase 2 of making LAN DNS survive a node failure (phase 1:
|
||||||
|
`decisions/2026-10-04-pihole-declare-state.md`). PiHole was one pod on one node, which for a
|
||||||
|
whole-house resolver cost more than "until it reschedules" — measured 2026-10-04:
|
||||||
|
|
||||||
|
| Failure | Recovery |
|
||||||
|
|---|---|
|
||||||
|
| the node running the pod dies | ~40s to mark NotReady + `tolerationSeconds: 300` before eviction + reschedule + a Longhorn RWO force-detach → **roughly 6-10 minutes with no LAN DNS** |
|
||||||
|
| the node holding the `pihole-dns-*` L2 lease dies | Cilium re-elects an announcer, tens of seconds |
|
||||||
|
|
||||||
|
Those were **different** nodes (lease on k3s-prod-2, pod on k3s-prod-1), so two of three nodes were
|
||||||
|
single points of failure for LAN DNS with wildly different blast radii.
|
||||||
|
|
||||||
|
**A new schema field, because this is intent and not a primitive.** `availability.replicas`
|
||||||
|
(schema **1.10.0**, mechanism in `schemas/renderer-contract.md` `### availability`). `1` or absent
|
||||||
|
renders a Deployment exactly as before — every pre-existing golden is byte-identical, verified. `>1`
|
||||||
|
renders, together:
|
||||||
|
|
||||||
|
- a **StatefulSet** instead of a Deployment. This is forced, not preferred: a longhorn PVC is
|
||||||
|
**ReadWriteOnce**, and one RWO claim cannot attach on two nodes, so a 2-replica Deployment does not
|
||||||
|
degrade — the second pod never schedules.
|
||||||
|
- longhorn storage as **per-replica `volumeClaimTemplates`** (`config-pihole-0`, `config-pihole-1`);
|
||||||
|
the standalone PVC is no longer emitted, since it would be an orphan nothing mounts. A `bulk`
|
||||||
|
entry stays a standalone PVC — it is a shared static NFS export, not per-replica state.
|
||||||
|
- **required** `podAntiAffinity` on `kubernetes.io/hostname`. `preferred` would let both replicas
|
||||||
|
land on one node and deliver none of the intended tolerance while reporting healthy.
|
||||||
|
- `podManagementPolicy: Parallel`, so a cold start costs the max of the replicas' first-boot work
|
||||||
|
rather than the sum — `apps-wave-0` has `timeout: 5m`.
|
||||||
|
- a **PodDisruptionBudget**, `minAvailable: 1`, so a node drain cannot take the last replica.
|
||||||
|
- `observability.livenessWorkload.kind` defaulting to `StatefulSet`, so the `up` SLI reads
|
||||||
|
`kube_statefulset_status_replicas_ready`. A Deployment-shaped rule against a StatefulSet
|
||||||
|
`absent()`s forever — an alert that can never fire.
|
||||||
|
|
||||||
|
`catalog/pihole.yaml` sets `replicas: 2`, not 3: two tolerates any single node loss and leaves a
|
||||||
|
spare node for the evicted replica under the hard anti-affinity. Three would pin one per node with no
|
||||||
|
spare, so a single node failure would leave a replica permanently `Pending` and — `apps-wave-0` being
|
||||||
|
`wait: true` — stall the app tier for as long as the node was down.
|
||||||
|
|
||||||
|
### The bug found on the way, and fixed here
|
||||||
|
|
||||||
|
**PiHole's config PVC was mounted at a path PiHole never writes, so it held nothing and all of its
|
||||||
|
state was ephemeral.** With no explicit `mountPath` the renderer defaults to `/data/<name>`; PiHole
|
||||||
|
keeps everything in `/etc/pihole`. Measured on live prod 2026-10-04: `/etc/pihole` (7.1 M —
|
||||||
|
`gravity.db`, `pihole.toml`, the query DB) was on the **overlay** filesystem, while the 2 Gi Longhorn
|
||||||
|
volume sat at `/data/config` holding `lost+found` and 24 K of nothing. Consequences, all live until
|
||||||
|
now:
|
||||||
|
|
||||||
|
- every restart silently discarded PiHole's entire state and re-downloaded 72 k gravity domains;
|
||||||
|
- `backup.policy: daily-offcluster` has been faithfully backing up an **empty** volume, which is
|
||||||
|
worse than no backup because it reports success;
|
||||||
|
- it also means phase 1 was understated: that state was not merely undeclared, it was **ephemeral**.
|
||||||
|
|
||||||
|
Same class of bug `catalog/plex.yaml` documents for its own `config` entry. Fixed in this branch
|
||||||
|
rather than its own because with the wrong path the new per-replica volumes would be two 2 Gi
|
||||||
|
Longhorn volumes (×3 Longhorn replicas each) holding only `lost+found` while all state stayed
|
||||||
|
ephemeral — paying for persistence and getting none. It also removes this change's main operational
|
||||||
|
risk: with `gravity.db` persisted, a restart is cheap instead of re-downloading 72 k domains inside a
|
||||||
|
wave with a 5 m timeout.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **Back-compatibility, the thing most likely to break quietly:** after the renderer change and
|
||||||
|
before `catalog/pihole.yaml` declared anything, the canonical
|
||||||
|
`render.py --all catalog -o apps --wave-layout --no-image-automation` produced **zero diff** across
|
||||||
|
the whole `apps/` tree. `render-golden` passes.
|
||||||
|
- **The new behaviour is pinned by ~20 new `render.py --selftest` assertions** (so `ci/render-golden.sh`
|
||||||
|
hard-fails on a regression, and no `conformance/**` change was needed): `replicas: 1` still renders a
|
||||||
|
Deployment + standalone PVC + no PDB; `replicas: 2` renders a StatefulSet and **no** standalone
|
||||||
|
longhorn PVC, carries the replica count, omits `strategy`, sets `Parallel` and `serviceName`, names
|
||||||
|
the claim template exactly as the mounted volume, keeps RWO/longhorn on it, does **not** duplicate
|
||||||
|
that volume in `pod.volumes`, still mounts it on the container, renders required hostname
|
||||||
|
anti-affinity, renders exactly one `minAvailable: 1` PDB, and switches the SLI to
|
||||||
|
`kube_statefulset_status_replicas_ready`. A mixed longhorn+bulk case asserts the bulk share stays a
|
||||||
|
standalone PVC and an ordinary pod volume.
|
||||||
|
- **Rendered pihole output, read back:** `replicas: 2`, `serviceName: pihole`,
|
||||||
|
`podManagementPolicy: Parallel`, no `strategy`, claim template `config` (RWO/longhorn/2Gi),
|
||||||
|
anti-affinity `kubernetes.io/hostname`, `pod.volumes: []`, mount `config -> /etc/pihole`,
|
||||||
|
PDB `minAvailable: 1`, SLI `kube_statefulset_status_replicas_ready`.
|
||||||
|
- **The `/etc/pihole` mount is safe, evidenced from the image's own init rather than assumed:**
|
||||||
|
`/usr/bin/start.sh` is PID 1 and runs as **uid 0**; its line 5 is
|
||||||
|
`chown -R pihole:pihole /etc/pihole/ /var/log/pihole/`, so a fresh root-owned volume has its
|
||||||
|
ownership fixed before anything else starts (**no `fsGroup` needed, and one would be inert**); it
|
||||||
|
calls `migrate_gravity`, whose `migrate_to_database()` creates the gravity database when
|
||||||
|
`[ ! -e "$gravityDBfile" ]`; FTL writes `pihole.toml` itself from the `FTLCONF_*` env; and the
|
||||||
|
image's own default for `files.macvendor` was moved **out** of that directory, which is what a
|
||||||
|
maintainer does to a path they expect to be a volume.
|
||||||
|
- **The PVC really is unused:** `df` showed `/etc/pihole` on `overlay` and
|
||||||
|
`/dev/longhorn/pvc-849edb86-...` on `/data/config`; `ls -A /data/config` → `lost+found` only.
|
||||||
|
- **The gate caught a real regression and it is fixed.** The first run came back **rc 1**:
|
||||||
|
`alert-rules-unit-tested` failed because `apps/wave-0/tests/app-slis.test.yaml` fed pihole a
|
||||||
|
`kube_deployment_status_replicas_available` series and expected a `deployment: pihole` label. Three
|
||||||
|
input series, the firing expectation and the file header were updated;
|
||||||
|
`platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml` → **SUCCESS** (6 rules).
|
||||||
|
- **Schema:** `check-jsonschema` passes over all of `catalog/*.yaml`.
|
||||||
|
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||||
|
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two environmental skips —
|
||||||
|
`gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE`
|
||||||
|
unset). **Not green; reported as rc 3.**
|
||||||
|
- **No sensitive paths touched**, so unlike the previous two branches this one needs no
|
||||||
|
`/approve-override` — confirmed against `infra/gitea/approval-policy.json`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Nothing has been reconciled.** No StatefulSet exists, no second replica has ever run, the
|
||||||
|
anti-affinity has never been evaluated by the scheduler, the PDB has never been consulted, and the
|
||||||
|
`/etc/pihole` mount has never been exercised on a real pod. Everything above is the rendered
|
||||||
|
artifact plus the image's own source.
|
||||||
|
- **THE ROLLOUT WILL BRIEFLY DROP LAN DNS, and I have not measured for how long.** `postStart` must
|
||||||
|
complete before a container is reported Running, so a pod is not Ready — and the Service has no
|
||||||
|
endpoints — until phase 1's adlist hook finishes. On this transition both replicas start with an
|
||||||
|
empty `/etc/pihole`, so the hook finds a fresh `gravity.db` with zero adlists and runs a real
|
||||||
|
`pihole -g` (72 k domains, network download) before either pod becomes Ready. **Do this at a quiet
|
||||||
|
time.** Two mitigating facts: every pihole restart has *already* paid this cost (state was
|
||||||
|
ephemeral), and after this change `gravity.db` persists so later restarts should not. If it turns
|
||||||
|
out slow, backgrounding the gravity rebuild inside the hook would decouple readiness from it, at
|
||||||
|
the cost of a window where DNS resolves but does not block — not done here.
|
||||||
|
- **Whether `pihole -g` finishes inside `apps-wave-0`'s 5 m `timeout`.** `Parallel` was chosen to
|
||||||
|
make this the max rather than the sum of two first boots, but the real duration is unmeasured (it
|
||||||
|
was stubbed during phase 1's testing). If the wave times out, the Kustomization reports failed even
|
||||||
|
though the pods may come up seconds later.
|
||||||
|
- **The old PVC's deletion.** `apps-wave-0` has `prune: true`, so removing `pihole-config` from the
|
||||||
|
rendered output should delete it. That destroys nothing (it holds `lost+found`), but I have not
|
||||||
|
observed the prune, and I did not check whether Longhorn retains the backing volume afterwards.
|
||||||
|
- **The transition overlap.** For a moment the pruned Deployment's pod and the new StatefulSet pods
|
||||||
|
may both match the Service selector. Both are configured identically from git so this should be
|
||||||
|
benign — unobserved.
|
||||||
|
- **Longhorn capacity.** Two 2 Gi claims at 3 Longhorn replicas each is ~12 Gi of real storage where
|
||||||
|
there was ~6 Gi. Not checked against free space.
|
||||||
|
- **Whether two independent query databases are acceptable to the operator.** Stats and query history
|
||||||
|
now split per replica, and the admin UI shows only the pod that answered. Adlists and password are
|
||||||
|
identical (phase 1), so blocking and auth are consistent; the *reporting* is not.
|
||||||
|
- **The residual risk is unchanged and is the larger one.** Replicas address node failure. LAN DNS
|
||||||
|
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||||
|
outside the cluster carrying the same `address=` lines removes that. Considered and not chosen
|
||||||
|
(operator decision, 2026-10-04).
|
||||||
|
- **`tolerationSeconds: 300` is untouched.** With two replicas the surviving one serves immediately, so
|
||||||
|
the 5-minute eviction delay no longer gates recovery — but it still delays the *replacement* replica,
|
||||||
|
and during that window there is no redundancy left.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: fix/plex-advertise-plexdirect
|
||||||
|
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
|
||||||
|
shows "not authorized"; connecting straight to the VIP + port works.
|
||||||
|
|
||||||
|
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
|
||||||
|
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
|
||||||
|
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
|
||||||
|
|
||||||
|
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
|
||||||
|
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
|
||||||
|
|
||||||
|
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
|
||||||
|
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
|
||||||
|
for every client. Not theoretical: Plex's log carries a continuous storm of
|
||||||
|
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
|
||||||
|
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
|
||||||
|
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
|
||||||
|
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
|
||||||
|
|
||||||
|
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
|
||||||
|
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
|
||||||
|
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
|
||||||
|
clients reported "not authorized".
|
||||||
|
|
||||||
|
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
|
||||||
|
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
|
||||||
|
|
||||||
|
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
|
||||||
|
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
|
||||||
|
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
|
||||||
|
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
|
||||||
|
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
|
||||||
|
placeholder** — dev is decommissioned and has no claimed server, and
|
||||||
|
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
|
||||||
|
and be correctly shaped.
|
||||||
|
- **`clusters/prod/kustomization.yaml`:** prod value
|
||||||
|
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
|
||||||
|
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
|
||||||
|
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
|
||||||
|
lines, nothing else.
|
||||||
|
|
||||||
|
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
|
||||||
|
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
|
||||||
|
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
|
||||||
|
halves of one address and **nothing checks that they agree** — noted in both files.
|
||||||
|
|
||||||
|
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK it.**
|
||||||
|
It needs the operator's `/approve-override <reason>` posted as a PR **review** of type "Comment".
|
||||||
|
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
|
||||||
|
account holds `owner`, so the prohibition is a rule rather than a limit.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
|
||||||
|
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
|
||||||
|
and the token was reported only as `<PRESENT,len=22>`.
|
||||||
|
|
||||||
|
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
|
||||||
|
rests on:
|
||||||
|
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
|
||||||
|
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
|
||||||
|
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
|
||||||
|
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
|
||||||
|
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
|
||||||
|
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
|
||||||
|
- **The certificate, read off the live listener:** exactly one SAN,
|
||||||
|
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
|
||||||
|
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
|
||||||
|
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
|
||||||
|
— all four unreachable from a LAN client or from plex.tv.
|
||||||
|
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
|
||||||
|
session — the documented fallback when nothing is directly publishable.
|
||||||
|
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
|
||||||
|
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
|
||||||
|
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
|
||||||
|
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
|
||||||
|
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
|
||||||
|
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
|
||||||
|
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
|
||||||
|
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
|
||||||
|
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
|
||||||
|
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
|
||||||
|
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
|
||||||
|
rules and no CCNPs exist anywhere in the cluster.
|
||||||
|
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
|
||||||
|
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
|
||||||
|
transcoder segment numbers).
|
||||||
|
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
|
||||||
|
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
|
||||||
|
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
|
||||||
|
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
|
||||||
|
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||||
|
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||||
|
**pass**. The rc 3 is the two known environmental skips — `gitea-settings` (no local
|
||||||
|
`SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
|
||||||
|
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
|
||||||
|
the rendered diff is exactly two lines and no other service's manifest moved.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
|
||||||
|
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
|
||||||
|
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
|
||||||
|
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
|
||||||
|
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
|
||||||
|
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
|
||||||
|
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
|
||||||
|
the server as remotely unreachable even when local clients work.
|
||||||
|
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
|
||||||
|
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
|
||||||
|
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
|
||||||
|
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
|
||||||
|
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
|
||||||
|
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
|
||||||
|
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
|
||||||
|
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
|
||||||
|
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
|
||||||
|
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
|
||||||
|
typed them while debugging. I did not alter live preferences; the env var will overwrite
|
||||||
|
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
|
||||||
|
to git and will **silently discard** anything typed there since.
|
||||||
|
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
|
||||||
|
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
|
||||||
|
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
|
||||||
|
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
|
||||||
|
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
|
||||||
|
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
|
||||||
|
way the fix still fails.
|
||||||
|
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
|
||||||
|
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
|
||||||
|
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
|
||||||
|
something restarted Plex inside the container. Unexplained and not investigated.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: fix/plex-unblock-app-tier
|
||||||
|
title: Unblock the app tier — my Certificate stalled apps-wave-0, and the advertised https URL had no servable certificate
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**I broke the app tier with PR #48 and this reverts the part that did it.** Two faults, both mine,
|
||||||
|
both measured after the merge:
|
||||||
|
|
||||||
|
1. **Kyverno blocks cert-manager in app namespaces.** The temporary private-key Secrets
|
||||||
|
(`plex-vip-<random>`) were denied by `secret-provenance/secrets-must-be-flux-managed`:
|
||||||
|
|
||||||
|
resource Secret/plex/plex-vip-rp4qj was blocked due to the following policies
|
||||||
|
secret-provenance:
|
||||||
|
secrets-must-be-flux-managed: Secrets in application namespaces must be delivered by
|
||||||
|
Flux from SOPS-encrypted git ... or be exempt controller-generated Secrets.
|
||||||
|
|
||||||
|
The policy matches every Secret in a namespace labelled
|
||||||
|
`homelab.keithjackson.dev/app-namespace: true` and excludes only Secrets labelled
|
||||||
|
`homelab.keithjackson.dev/secret-managed-by: controller` or carrying a Flux label. **The `plex`
|
||||||
|
namespace has that label; the `gateway` namespace — where the working wildcard cert lives — does
|
||||||
|
NOT.** That asymmetry is why the existing Certificate was never affected and mine was, and I did
|
||||||
|
not check it. cert-manager's temp key Secrets carry neither exempting label, and `secretTemplate`
|
||||||
|
cannot label them (the keymanager controller creates them, not the Certificate template). Result:
|
||||||
|
**no CertificateRequest, Order or Challenge was ever created.**
|
||||||
|
|
||||||
|
2. **Flux health-checks the Certificate — guarding the pod was not enough.** `optional: true` did
|
||||||
|
exactly what it was designed to do: Plex rolled and ran fine without the Secret (verified). But
|
||||||
|
`apps-wave-0` is `wait: true`, so Flux waits on every applied object with a known health type:
|
||||||
|
|
||||||
|
health check failed after 5m0.045564329s: timeout waiting for:
|
||||||
|
[Certificate/plex/plex-vip status: 'InProgress']
|
||||||
|
|
||||||
|
apps-wave-0 never went Ready, and apps-wave-1/-2 plus everything downstream of platform-config
|
||||||
|
sat in `DependencyNotReady` behind it. I reasoned about the pod and missed the object.
|
||||||
|
|
||||||
|
A third, independent problem the same PR introduced: **the advertised URL was `https://` with no
|
||||||
|
servable certificate.** Plex answers 32400 with its `*.<serverhash>.plex.direct` cert, which cannot
|
||||||
|
cover `plex-vip.${BASE_DOMAIN}` — the precise failure mode of the outage earlier that day.
|
||||||
|
|
||||||
|
- **`apps/wave-0/plex-cert.yaml` DELETED**, and `plex-cert.yaml` removed from
|
||||||
|
`apps/wave-0/kustomization.yaml` `resources`, so Flux prunes the stuck Certificate and the health
|
||||||
|
gate clears. **Deleted rather than merely unregistered** — my first attempt kept the file as
|
||||||
|
"tracked but not onboarded" (the shape `keycloak.yaml` and `vm-demo.yaml` use) and **the repo's own
|
||||||
|
gate rejected it**: `tls-lifecycle-automated` (R-TLS-02) fails any Certificate in the tree that no
|
||||||
|
Flux path reconciles — *"nothing reconciles it, so a deleted Certificate/issuer/controller would
|
||||||
|
stay deleted until a human re-applied it."* That pattern is legal for a Deployment and not for a
|
||||||
|
TLS object, which is a distinction I had not known.
|
||||||
|
- **`catalog/render.py`:** the now-dangling `plex-cert` EXTRA_UNITS entry removed.
|
||||||
|
- **The re-enable recipe is recorded at the call site** — in `catalog/plex.yaml` beside the scheme
|
||||||
|
it is coupled to — rather than in the deleted file: the Kyverno exemption, the non-gating
|
||||||
|
Kustomization, and the `https` flip, as one change.
|
||||||
|
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → **`http://`**`plex-vip.${BASE_DOMAIN}:32400`, explicitly
|
||||||
|
temporary, with the restore condition written at the call site.
|
||||||
|
|
||||||
|
**Everything else from PR #48 is kept and working** — the hostname design, the PiHole split-horizon
|
||||||
|
answer, the `LanNetworksBandwidth` hook, the `optional` schema field, and the TLS env (inert without
|
||||||
|
the Secret, so re-enabling is additive).
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, 2026-10-04, read-only, after the #48/#50 merges.
|
||||||
|
|
||||||
|
- **The stall and its exact cause**: the health-check timeout message above, and the full dependency
|
||||||
|
cascade (`cilium` → "dependency 'flux-system/flux-system' revision is not up to date", everything
|
||||||
|
else `DependencyNotReady`). The root `cilium` wait was a normal revision-gated cascade; the
|
||||||
|
*Certificate* was the real blocker.
|
||||||
|
- **The Kyverno denial text, verbatim**, from the cert-manager controller log, repeating every ~30s
|
||||||
|
across at least 15 distinct Secret names.
|
||||||
|
- **The policy's own structure**, read from `platform/kyverno/admission/secret-provenance.yaml`:
|
||||||
|
match = Secrets in `app-namespace: true` namespaces; exclude = `secret-managed-by: controller` OR
|
||||||
|
a `kustomize.toolkit.fluxcd.io/name` label; validate = unconditional `deny`.
|
||||||
|
- **The namespace-label asymmetry**: `plex` carries
|
||||||
|
`homelab.keithjackson.dev/app-namespace: "true"`; `gateway` does not.
|
||||||
|
- **cert-manager's controller ServiceAccount is `cert-manager`** in namespace `cert-manager` — the
|
||||||
|
identity a future exemption should name.
|
||||||
|
- **`optional: true` worked**: the new pod (`plex-c4f87c84d-zgjq5`) is `1/1 Running` with no Secret
|
||||||
|
present, and `/config/certs` was never created.
|
||||||
|
- **The postStart hook RAN and took effect** — the thing the whole hook was written for:
|
||||||
|
`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is now set in the live `Preferences.xml`, and
|
||||||
|
`customConnections="https://plex-vip.home.keithjackson.dev:32400"` was applied from the env. The
|
||||||
|
certificate prefs are correctly **ABSENT**, i.e. the hook's "no bundle → leave them alone" guard
|
||||||
|
behaved as designed.
|
||||||
|
- **PiHole's split-horizon answer is correct**, asked of `pihole-0` directly:
|
||||||
|
`plex-vip.home.keithjackson.dev -> 192.168.2.24`, alongside `plex -> .20` and `crafty-mc -> .22`,
|
||||||
|
and the live `/etc/pihole/dnsmasq.conf` carries
|
||||||
|
`address=/plex-vip.home.keithjackson.dev/192.168.2.24`. **This corrects my own earlier check**,
|
||||||
|
which read `.20` and which I nearly reported as a failure — that test resolved via CLUSTER DNS
|
||||||
|
(CoreDNS) from inside a pod and never consulted PiHole at all.
|
||||||
|
- **The gate caught my first attempt and I fixed it rather than working around it.** Keeping the
|
||||||
|
file unregistered produced `MERGE GATE: BLOCKED` (rc 1) with
|
||||||
|
`CHECK FAIL (tls-lifecycle-automated): 1 of 612 assertions failed`. After deleting the file and
|
||||||
|
the dangling EXTRA_UNITS entry: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||||
|
`render-golden`, `l1` and `approval-policy` all **pass** — the rc 3 being only the two known
|
||||||
|
environmental skips (`gitea-settings`, `conformance` L2/L3). **Not green. Reported as rc 3.**
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Whether this actually unblocks the tier.** Unreconciled at time of writing. The reasoning is
|
||||||
|
that pruning the Certificate removes the only failing health check, but I have not seen
|
||||||
|
apps-wave-0 go Ready.
|
||||||
|
- **Whether `http://` restores client connectivity.** PiHole resolves the name and Plex serves 32400
|
||||||
|
(both measured), but I have not watched a Plex client complete a connection over the advertised
|
||||||
|
URL, and I did not re-check whether plex.tv publishes it.
|
||||||
|
- **THE REMOTE BADGE — the symptom that started this.** `LanNetworksBandwidth` is set, but the log
|
||||||
|
still tags traffic `Allowed Network (WAN)`: **82 WAN, 0 LAN**, with entries continuing after the
|
||||||
|
pref was written. So either Plex needs a restart for it to take effect, or that log tag is not
|
||||||
|
driven by this pref at all. **I do not know which, and the user-visible badge is the only real
|
||||||
|
test** — it is in the Plex client, which I cannot see.
|
||||||
|
- **The Hubble flow export is NOT active.** The cilium agents are still the pre-merge pods, so the
|
||||||
|
`filePath: stdout` question is still open and no flow has been exported. The Cilium rollout — and
|
||||||
|
its LAN-DNS risk, since prod-3 holds the Gateway, PiHole-DNS and Plex VIPs — is still AHEAD.
|
||||||
|
- **Whether a cert-manager exemption is the right shape**, or whether it widens
|
||||||
|
`secret-provenance` more than the operator wants. It is `platform/kyverno/**`, a sensitive path,
|
||||||
|
and deliberately not attempted here.
|
||||||
|
- **Whether the pruned Certificate leaves residue** — I did not check for orphaned
|
||||||
|
`plex-vip-<random>` Secrets or CertificateRequests after the prune. Note Kyverno DENIED every one
|
||||||
|
of those Secrets, so there may be nothing to clean, but that is an inference, not a check.
|
||||||
|
- **Whether `tls-lifecycle-automated` would also have objected to the Certificate living in a
|
||||||
|
`wait: false` Kustomization** (the recommended re-enable shape). It demands a reconciling Flux
|
||||||
|
path, which that provides, but I did not test it.
|
||||||
|
- **The unencrypted advertised URL.** `http://` is tolerable only while remote access does not work
|
||||||
|
(no WAN forward). Nothing enforces that assumption, and nothing will complain if `http` is still
|
||||||
|
there the day the port-forward appears.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-04
|
||||||
|
branch: feat/plex-vip-hostname-and-lan-networks
|
||||||
|
title: Plex — one advertised hostname for local and remote, its own LE certificate, and a LAN definition so LAN clients stop being reported as remote
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Two operator-reported symptoms, after the 2026-10-04 "server missing from the account" fix
|
||||||
|
(decisions/2026-10-04-plex-advertise-plexdirect.md) had restored publishing:
|
||||||
|
|
||||||
|
1. **LAN clients were shown as Remote.** Root cause, measured: `LanNetworksBandwidth` was **ABSENT**,
|
||||||
|
so Plex had no definition of a local network and classified *everything* as WAN — **468 requests
|
||||||
|
logged `Allowed Network (WAN)` and ZERO `(LAN)`**, including Seerr dialling from inside the
|
||||||
|
cluster. `allowedNetworks` (the `ALLOWED_NETWORKS` env) only waives **auth**; it does not make a
|
||||||
|
client local. Two different settings, and only one of them has an env hook.
|
||||||
|
2. **The advertised URL was a single-purpose, fragile value.** The previous fix advertised
|
||||||
|
`https://<dashed-ip>.<serverhash>.plex.direct:32400`, which works, but hard-codes the VIP *and*
|
||||||
|
the server's certificate hash into a cluster-var — two values nothing could keep in sync.
|
||||||
|
|
||||||
|
**Operator design adopted (2026-10-04): ONE hostname, `plex-vip.${BASE_DOMAIN}`, serving both
|
||||||
|
audiences by split-horizon DNS** — PiHole answers it with `${PLEX_L4_VIP}` on the LAN, Cloudflare
|
||||||
|
answers it publicly with the WAN address, NATed to the same VIP.
|
||||||
|
|
||||||
|
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → `https://plex-vip.${BASE_DOMAIN}:32400`. New
|
||||||
|
`lifecycle.postStart` hook (level-triggered, same idiom as pihole's and qbittorrent's) that
|
||||||
|
re-asserts on **every** start: `LanNetworksBandwidth`, and the three `customCertificate*` prefs
|
||||||
|
after converting the PEM to the PKCS#12 bundle Plex requires. New `PLEX_TLS_CRT`/`PLEX_TLS_KEY`
|
||||||
|
env from the `plex-vip-tls` Secret, both `optional: true`.
|
||||||
|
- **`apps/wave-0/plex-cert.yaml` (new, hand-written):** a cert-manager `Certificate` for exactly
|
||||||
|
`plex-vip.${BASE_DOMAIN}` from ClusterIssuer `letsencrypt-prod`. Non-catalog because the schema has
|
||||||
|
no field for a Certificate. A single name, not the wildcard: the wildcard's Secret lives in
|
||||||
|
`gateway` and Secrets are not readable across namespaces, and re-requesting `*.${BASE_DOMAIN}`
|
||||||
|
would burn a duplicate-certificate slot for no benefit.
|
||||||
|
- **`schemas/servicedefinition.schema.json` → 1.11.0:** adds `optional` to
|
||||||
|
`env.valueFrom.secretKeyRef` / `configMapKeyRef`. **This is load-bearing, not tidiness.** The
|
||||||
|
Certificate must be issued into the `plex` namespace, and that namespace is created by the
|
||||||
|
rendered `apps/wave-0/plex.yaml` itself — so it cannot be issued in an earlier tier and lands in
|
||||||
|
the *same* reconcile as the workload. With hard refs, Plex would sit in
|
||||||
|
`CreateContainerConfigError` until a certificate issued, and `apps-wave-0` is `wait: true` with
|
||||||
|
`apps-wave-1` dependsOn it, so a slow or failed DNS01 challenge would have **stalled the whole app
|
||||||
|
tier** — the hazard `catalog/plex.yaml` already documents for its GPU resource.
|
||||||
|
- **`catalog/pihole.yaml`:** `address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}` added to
|
||||||
|
`FTLCONF_misc_dnsmasq_lines`. Required, not cosmetic: measured 2026-10-04,
|
||||||
|
`plex-vip.${BASE_DOMAIN}` resolved to **`192.168.2.20`** (the Gateway VIP) via the zone wildcard,
|
||||||
|
and a connect to `:32400` there returned **`http=000`**.
|
||||||
|
- **`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml`:** `PLEX_ADVERTISE_URL`
|
||||||
|
**deleted** — the hostname makes it unnecessary, which removes the cert-hash fragility outright.
|
||||||
|
`PLEX_L4_VIP`'s note now records its second consumer (pihole).
|
||||||
|
- **`catalog/render.py`:** `plex-cert` EXTRA_UNITS entry; **`apps/wave-0/kustomization.yaml`:**
|
||||||
|
`plex-cert.yaml` hand-added (NOT via `--emit-waves`, which would also onboard `keycloak` and
|
||||||
|
`vm-demo` — the pre-existing drift recorded on 2026-10-03).
|
||||||
|
|
||||||
|
**A correction to my own earlier reasoning, recorded because it was wrong in the handoff log.** The
|
||||||
|
previous entry asserted a bare-IP URL "can NEVER validate" and treated a SAN match as a
|
||||||
|
*requirement*. The operator pointed out it is not: `httpsRequired=0` and `secureConnections` default
|
||||||
|
to Preferred, so clients can fall back to cleartext. The measurements still stand (a validating
|
||||||
|
client did fail, and real clients did send `certificate unknown`) — but the correct framing is that a
|
||||||
|
matching certificate keeps the remote leg **encrypted**, not that it is required for Plex to
|
||||||
|
function. That is why this branch obtains a real certificate for the advertised name rather than
|
||||||
|
choosing the name to suit Plex's built-in one.
|
||||||
|
|
||||||
|
**SENSITIVE PATHS — touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK until an
|
||||||
|
approving review (or the operator's `/approve-override <reason>` posted as a PR *review* of type
|
||||||
|
"Comment"). Deliberately not self-posted: CLAUDE.md §1.**
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, 2026-10-04, read-only. Secrets per CLAUDE.md §6: prefs were read by name-scoped grep,
|
||||||
|
the server token was used inside the pod and never printed, and no `Preferences.xml` was dumped.
|
||||||
|
|
||||||
|
- **The LAN-classification root cause, directly:** `LanNetworksBandwidth=<ABSENT>`, and request
|
||||||
|
tagging of **468 × `Allowed Network (WAN)` vs 0 × `(LAN)`** — including `10.0.2.33` (Seerr), an
|
||||||
|
in-cluster pod. This is the measurement the whole hook rests on.
|
||||||
|
- **All four prefs the hook sets EXIST on this build**, read from the server's own `/:/prefs`
|
||||||
|
(token-authenticated, output filtered to ids): `LanNetworksBandwidth`, `customCertificatePath`,
|
||||||
|
`customCertificateKey`, `customCertificateDomain`, all `type="text"`. I had earlier been unable to
|
||||||
|
confirm this (an unauthenticated `GET /:/prefs` returns **401**) and said so rather than assuming.
|
||||||
|
- **There is NO env path for these settings** — the reason the hook exists. Grep of the image's
|
||||||
|
entire init surface: `setPref` is called for exactly six prefs (`MachineIdentifier`,
|
||||||
|
`ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`,
|
||||||
|
`TranscoderTempDirectory`) and the only `PLEX_*` env it reads are CLAIM/CLAIM_FILE/UID/GID/
|
||||||
|
UPDATE_CHANNEL and five INFO/HOME vars. No `PLEX_PREFERENCE*` of any kind; zero files mention
|
||||||
|
`LanNetworks`.
|
||||||
|
- **The DNS gap was real:** `plex-vip.${BASE_DOMAIN}` resolved to `192.168.2.20` (Gateway VIP), and
|
||||||
|
`https://192.168.2.20:32400` returned `http=000` (nothing listening). PiHole's wildcard is why.
|
||||||
|
- **`openssl` is present in the image** (`/usr/bin/openssl`, 3.0.13) — the hook's PEM→PKCS#12
|
||||||
|
conversion has a tool to run.
|
||||||
|
- **The issuer exists and works:** ClusterIssuer `letsencrypt-prod` `READY=True` (28d), and the
|
||||||
|
existing `gateway/wildcard-home` Certificate is `Ready`/not-expired, which is what makes a DNS01
|
||||||
|
issuance for a new name in this zone a reasonable expectation.
|
||||||
|
- **The renderer's substitution guard caught a real defect in my own work**, which is recorded
|
||||||
|
because it is the second time this class of bug has appeared here: my hook's explanatory comment
|
||||||
|
contained brace-wrapped names, and `assert_substitution_safe` refused to emit
|
||||||
|
(`'PX' is not a declared cluster-vars key — envsubst will replace it with the EMPTY STRING`).
|
||||||
|
Reworded; the rendered hook now contains exactly one brace-wrapped name, `BASE_DOMAIN`, verified
|
||||||
|
by parsing the emitted manifest.
|
||||||
|
- **A `set -u` bug I introduced and then removed.** With `optional: true` the TLS env vars are
|
||||||
|
genuinely UNSET when no certificate has issued; under `set -u` the first `[ -n ... ]` would abort
|
||||||
|
the hook, and a postStart hook exiting non-zero makes kubelet KILL the container — the exact
|
||||||
|
failure `optional` exists to prevent. Verified by parsing the rendered manifest that no `set -u`
|
||||||
|
**command** remains (only two mentions in comments).
|
||||||
|
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||||
|
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||||
|
**pass**; the rc 3 is the two known environmental skips (`gitea-settings` — no local
|
||||||
|
`SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE` unset). **Not green. Reported as
|
||||||
|
rc 3.** `l0` passing is the specific proof the schema edit admits `optional` and still validates
|
||||||
|
every catalog file; `render-golden` passing is the proof catalog and committed goldens agree.
|
||||||
|
- **Only plex and pihole goldens moved.** No other service's manifest changed, and no service was
|
||||||
|
silently onboarded.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE OUTCOME, ENTIRELY. Nothing in this branch has been observed working.** It is unreconciled:
|
||||||
|
no Certificate exists in prod, `plex-vip` still resolves to the Gateway VIP, `LanNetworksBandwidth`
|
||||||
|
is still absent, and the hook has NEVER RUN. Every claim above is about the inputs, not the result.
|
||||||
|
- **Whether the certificate will actually ISSUE.** DNS01 for a new name in this zone is inferred from
|
||||||
|
the wildcard's success, not demonstrated. If the Cloudflare credential or solver is scoped in a way
|
||||||
|
that fails for this name, Plex will start (by design) with no TLS and the advertised `https://` URL
|
||||||
|
will be unservable — i.e. **this branch could leave the advertised URL broken in a new way**. First
|
||||||
|
reconcile must be watched.
|
||||||
|
- **Whether `openssl pkcs12 -export` produces a bundle PLEX ACCEPTS.** OpenSSL 3 defaults to AES-256
|
||||||
|
+ SHA-256, which some consumers reject. Untested against Plex. If it is rejected, Plex may fail to
|
||||||
|
load its TLS listener — the hook guards against a *missing* file, NOT against a present-but-
|
||||||
|
unreadable one. **This is the single largest unverified risk in the change.**
|
||||||
|
- **Whether the certificate prefs take effect without a restart.** Plex may need to re-initialise its
|
||||||
|
listener after `customCertificatePath` changes. If so, the first reconcile applies the prefs and
|
||||||
|
the SECOND start serves the cert. Converges either way, but the first start may look wrong.
|
||||||
|
- **Whether `LanNetworksBandwidth` actually flips the Remote badge.** It is the correct setting by
|
||||||
|
Plex's own documentation and the measured absence explains the symptom exactly, but I did not set it
|
||||||
|
live and observe a client reclassify.
|
||||||
|
- **SNAT makes the LAN definition coarser than it looks.** With `externalTrafficPolicy: Cluster`, Plex
|
||||||
|
sees the ingress NODE's address for everything arriving via the VIP, so once `10.0.0.0/8` and
|
||||||
|
`192.168.0.0/16` are LAN, GENUINE REMOTE clients arriving that way will ALSO be classified local
|
||||||
|
and remote bandwidth limits will not bind on them. This is the metering consequence already
|
||||||
|
accepted on 2026-10-03, now made stronger rather than weaker. Not measured.
|
||||||
|
- **The public Cloudflare record for `plex-vip` is off-repo and absent from this branch**, as is the
|
||||||
|
WAN tcp/32400 port-forward. Remote access cannot work until the operator creates both; nothing here
|
||||||
|
can verify either, and this repo has no off-LAN vantage point.
|
||||||
|
- **The private key is exposed in the container's environment** (`/proc/1/environ`) because the schema
|
||||||
|
and renderer support no secret VOLUME — measured: `storage` accepts only
|
||||||
|
[class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`, and
|
||||||
|
no rendered app mounts a Secret. A volume would be the better shape; adding secret-volume support
|
||||||
|
is the clean follow-up and I did not do it.
|
||||||
|
- **The PKCS#12 passphrase is a hardcoded literal** (`plexvip`) in the rendered manifest. It protects
|
||||||
|
nothing — Plex needs it in cleartext in `customCertificateKey` and it sits beside the bundle on the
|
||||||
|
same PVC — but it is worth knowing it is not a secret and was never treated as one.
|
||||||
|
- **Four places must agree and nothing enforces it:** `ADVERTISE_IP`, the hook's
|
||||||
|
`customCertificateDomain`, `plex-cert.yaml`'s `dnsNames`, and PiHole's answer. No gate stage or
|
||||||
|
conformance test checks that they match, or that PiHole's `plex-vip` answer still equals
|
||||||
|
`${PLEX_L4_VIP}`.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: docs/agents-work-in-their-own-workspace
|
||||||
|
title: One workspace per agent run — the shared checkout is now documented as off-limits
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Agents have been doing their work in the operator's checkout at
|
||||||
|
`/var/home/keith/source/home-network-k3s-impl` and clobbering each other. Nothing in the tree
|
||||||
|
told them not to: worktrees appeared in CLAUDE.md only as §3's *gate workarounds*, i.e. as an
|
||||||
|
awkward edge case, and `decisions/README.md` asserted worktree isolation as a fact about how
|
||||||
|
agents already behave rather than as a rule they must follow. Nowhere was the rule stated.
|
||||||
|
|
||||||
|
Documentation only — no code, no manifests, no pipeline logic.
|
||||||
|
|
||||||
|
- **`CLAUDE.md`: new `## 0. Work in your OWN workspace. Never in the shared checkout.`**
|
||||||
|
Placed before §1 so the section numbers every other file and handoff entry cites (§1, §2b,
|
||||||
|
§3, §7) all keep their meaning. It gives the three commands (`git fetch origin main`;
|
||||||
|
`git worktree add -b <type>/<slug> <path outside the repo> origin/main`; `cd`), an explicit
|
||||||
|
do-not list for the shared checkout (no `checkout`/`switch`, no edits, no
|
||||||
|
`add`/`commit`/`stash`/`restore`, no removing others' worktrees), a measurement table, the
|
||||||
|
"put it outside the repo root" rule, cleanup, what to do if you are already standing in the
|
||||||
|
shared checkout with edits, and what a PM must put in a brief it writes for another agent.
|
||||||
|
- **`CLAUDE.md` intro** now says "Start with §0", with the reason: it is the rule you can
|
||||||
|
break before you have read anything else.
|
||||||
|
- **`CLAUDE.md` §3** retitled "Running the gate from a worktree" and reframed as the normal
|
||||||
|
path rather than an edge case, since §0 now makes every gate run a worktree run. Its list
|
||||||
|
grew from two breakages to three: `spec-immutability`'s skip is *avoidable*, not merely
|
||||||
|
tolerated, if the `ENGINE` shim also mounts the sibling spec repo read-only and passes
|
||||||
|
`-e SPEC_REPO=/home-network-k3s`.
|
||||||
|
- **`CLAUDE.md` §4** gained "One branch, one workspace, one PR".
|
||||||
|
- **`README.md`**: a fourth non-negotiable rule ("No work in a shared checkout"), and
|
||||||
|
"Working on this repo" now opens with how to get out of the checkout before describing the
|
||||||
|
dev container that runs in it.
|
||||||
|
- **`decisions/README.md`**: the worktree-isolation sentence now cites §0 as the requirement
|
||||||
|
instead of describing it as something agents happen to do.
|
||||||
|
|
||||||
|
I did this work the way the new §0 requires — a worktree at
|
||||||
|
`<session scratchpad>/wt-workspace-rule`, outside the repo root, branched from `origin/main`,
|
||||||
|
with the shared checkout left on the branch and with the untracked files I found in it.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Every number in the new §0 table was measured in the shared checkout on 2026-10-05, not
|
||||||
|
recalled:
|
||||||
|
|
||||||
|
| Claim | Command | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| shared HEAD is yanked between branches | `git reflog \| grep -c 'checkout: moving'` | **107** |
|
||||||
|
| it is not sitting on `main` | `git worktree list` | `feat/pm-agent-issue-to-pr` |
|
||||||
|
| other runs' uncommitted work is in it | `git status --porcelain` | **2** trees: `infra/agent/` (22:04), `platform/pm-agent/` (22:09) — `stat -c %y` |
|
||||||
|
| its `main` is stale | `git rev-parse main` vs `origin/main` | `4386d14` vs `22c94ef`, **2 behind** |
|
||||||
|
| worktrees registered / already merged | `git worktree list --porcelain` + `git merge-base --is-ancestor <b> main` | **42** branches, **31** merged |
|
||||||
|
| nested worktrees are a gate hazard | `grep -rl '\.claude' conformance/tests platform/monitoring/tests` | **9** scripts carry an explicit exclusion |
|
||||||
|
| the 18× double-count | read `platform/monitoring/tests/run.sh:115-123` | in-tree comment, observed 2026-09-12 |
|
||||||
|
|
||||||
|
**The failure mode was observed live, mid-session, not just reconstructed.** The two untracked
|
||||||
|
trees I measured at 22:04 and 22:09 were committed at 22:20 by another run — `5334abe`
|
||||||
|
`feat(pm-agent): an in-cluster AI Program Manager...`, 1,000+ lines across `infra/agent/`,
|
||||||
|
`clusters/`, `catalog/`, `apps/`, `docs/plans/` and its own `decisions/` entry — onto the shared
|
||||||
|
checkout's checked-out branch. Measured after the fact: the shared checkout's HEAD moved
|
||||||
|
`22c94ef` → `5334abe` and its working tree went clean between my first `git status` and my last,
|
||||||
|
with `git log -1` naming a commit that is not mine. That is §0's "shared HEAD" row happening
|
||||||
|
during the session that wrote §0, and it is now cited in the doc.
|
||||||
|
|
||||||
|
**A claim of mine was wrong and is corrected in the doc.** I had it recorded that
|
||||||
|
`git worktree remove` is blocked by the agent sandbox. Provoked rather than assumed: created
|
||||||
|
a throwaway detached worktree in the scratchpad and removed it — **rc 0**, and
|
||||||
|
`git worktree list` no longer shows it. §0 therefore tells agents to remove their own, and
|
||||||
|
scopes the "blocked" note to what it actually covers: *other* sessions'
|
||||||
|
`.claude/worktrees/agent-*`, some of which are additionally `locked` on git's own terms. The
|
||||||
|
`rework/<pr-number>` escape hatch is now scoped to someone else's stale worktree holding a
|
||||||
|
branch, which is a real and separate problem (31 of them). Both of my own worktrees in this
|
||||||
|
session were removed cleanly at rc 0 after pushing.
|
||||||
|
|
||||||
|
`spec-immutability` is avoidable-not-tolerated: taken from
|
||||||
|
`decisions/2026-10-01-render-selftest-runs-in-gate.md`, which records
|
||||||
|
`stage spec-immutability: pass` under the two-mount shim plus `-e SPEC_REPO=/home-network-k3s`.
|
||||||
|
|
||||||
|
**Gate — `rc 3`, UNDETERMINED, and I am not calling it green.** Pinned toolbox, from this
|
||||||
|
worktree, `DEV_KUBECONFIG=/dev/null` and an `ENGINE` shim bind-mounting both the main
|
||||||
|
checkout's `.git` (at its own host path) and the sibling spec repo read-only with
|
||||||
|
`-e SPEC_REPO=/home-network-k3s`:
|
||||||
|
|
||||||
|
ENGINE=<two-mount shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' # rc 3
|
||||||
|
|
||||||
|
| stage | result |
|
||||||
|
|---|---|
|
||||||
|
| spec-immutability | **pass** — it genuinely RAN; the second mount is what makes it run from a worktree |
|
||||||
|
| approval-policy | pass, but **N/A**: `event='none'`. It cannot see a PR locally, so this green says nothing. See below |
|
||||||
|
| decisions-log | pass — the three required headings and the frozen-archive pin |
|
||||||
|
| lint · l0 · render-golden · l1 | pass (L1: 3 offline checks, 0 failures, 0 skips, 0 unwired) |
|
||||||
|
| gitea-settings | **SKIPPED** — `SETTINGS_API_TOKEN` unset; it exists only in CI |
|
||||||
|
| conformance (L2/L3) | SKIPPED — `CLUSTER_AVAILABLE!=true`; the decision notes no cluster-needing requirement was touched |
|
||||||
|
|
||||||
|
`gitea-settings` is the *only* stage that did not run, and it cannot be made to run from a
|
||||||
|
workstation, so rc 3 is the ceiling for any local run — not a defect in this branch. The
|
||||||
|
first gate attempt returned rc 3 for a different and real reason (`HEAD is not ahead of
|
||||||
|
origin/main`: I had not committed yet, so the touched-requirement set was empty by default
|
||||||
|
rather than by verification); it was re-run after committing, and the table above is the
|
||||||
|
post-commit run. §3 now records both expected skips, because it previously named only
|
||||||
|
`spec-immutability`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **GATE: UNVERIFIED at the time of writing.** The pinned-toolbox result for this branch and
|
||||||
|
its exit code are reported in the PR, not predicted here. **A local rc 3 is not green** and
|
||||||
|
will not be described as one.
|
||||||
|
- **CI gate: RED, measured — `MERGE GATE: BLOCKED`, one failing stage.** Run 245 on PR #56,
|
||||||
|
head `b988da8b`, 1m47s. The only failure is `approval-policy`: *"a sensitive change lacks a
|
||||||
|
non-author approval"*, `sensitive paths touched: CLAUDE.md`, author `gitea-admin`. Every other
|
||||||
|
stage passed — `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, and `gitea-settings`
|
||||||
|
(which can only run in CI, and reported `required_approvals = 0`,
|
||||||
|
`dismiss_stale_approvals = true`). `spec-immutability` SKIPPED in CI (no sibling spec repo in
|
||||||
|
the CI workspace — the inverse of the local run, where the two-mount shim made it pass) and
|
||||||
|
`conformance` SKIPPED. **Note `gate / conformance (pull_request)` reports status `success`
|
||||||
|
with description "Has been skipped"** — CLAUDE.md §2b's trap, live on this very PR. Nothing
|
||||||
|
in this branch is broken; the red is the structural human gate, below. *Measured at
|
||||||
|
`b988da8b`; this entry was then amended to record it, so the head SHA in the PR is newer than
|
||||||
|
the run quoted here and CI re-ran on the amended commit.*
|
||||||
|
- **This PR is a sensitive-path PR and is CI-RED on `approval-policy` until a human acts.**
|
||||||
|
`CLAUDE.md` is in `infra/gitea/approval-policy.json`'s `sensitive_paths` ("the delivery
|
||||||
|
contract agents follow"), so the stage requires an approving review from someone who is not
|
||||||
|
the author, or an `/approve-override <reason>` posted by an administrator **as a PR review
|
||||||
|
of type "Comment"**, not in the plain comment box. `ci/approval-policy.sh` run locally
|
||||||
|
reports N/A (it cannot see a PR), so the local gate cannot catch this and a green local run
|
||||||
|
says nothing about it. **I have not posted an override and must not** — CLAUDE.md §1 forbids
|
||||||
|
an agent clearing this on its own PR even though `gitea-admin` holds `owner` and could.
|
||||||
|
- **Nothing here is enforced.** §0 is prose. There is no gate stage that detects work done in
|
||||||
|
the shared checkout, no check that a worktree is outside the repo root, and no check that a
|
||||||
|
branch was cut from `origin/main`. Every other rule in CLAUDE.md that agents kept breaking
|
||||||
|
(`DECISIONS-LOG.md` appends, the three handoff headings) ended up as a gate stage, and that
|
||||||
|
is the honest follow-up here. I did not write one: the ask was documentation, and an
|
||||||
|
enforcement stage lands in `ci/**`, which is itself a sensitive path and deserves its own PR.
|
||||||
|
- **I did not clean up the 31 stale merged worktrees or the 35 nested under
|
||||||
|
`.claude/worktrees/`.** They are other sessions' workspaces; removing them is an operator
|
||||||
|
action, and §0 explicitly tells agents not to remove worktrees they did not create.
|
||||||
|
- **I did not touch the untracked `infra/agent/` and `platform/pm-agent/` trees** sitting in the
|
||||||
|
shared checkout. They are another run's in-flight work. Whose, and whether they are wanted,
|
||||||
|
is unknown to me.
|
||||||
|
- **`docs/plans/ssd-etcd-execution-runbook.md:18` still points at a `.claude/worktrees/` path**
|
||||||
|
inside the repo. Left as-is deliberately: that runbook is marked COMPLETED/historical and
|
||||||
|
describes the now-decommissioned dev cluster, so the path is a record of what was done, not
|
||||||
|
an instruction. It does mean a grep for in-repo worktree paths is not yet clean.
|
||||||
|
- **No runtime, cluster or Flux behaviour was touched or checked.** This branch changes three
|
||||||
|
Markdown files; `kubectl` was not run.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: feat/cilium-roll-out-on-config-change
|
||||||
|
title: Cilium config changes were silently inert — roll the agents when cilium-config changes, which also finally activates the Hubble exporter
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**A merged Cilium config change did nothing, and everything reported success.** The Hubble flow
|
||||||
|
exporter (PR #50, merged 2026-10-04) wrote its settings into `cilium-config` and never took effect.
|
||||||
|
|
||||||
|
| Measured 2026-10-05, over a day after the merge | |
|
||||||
|
|---|---|
|
||||||
|
| HelmRelease `cilium` | `Ready=True`, `UpgradeSucceeded`, *"Helm upgrade succeeded for release kube-system/cilium.v6"* |
|
||||||
|
| `cilium-config` keys | `hubble-export-file-path: stdout`, `hubble-export-allowlist: {"destination_port":["32400"]} {"source_port":["32400"]}` — **all present** |
|
||||||
|
| cilium agent pods | **all three still the PRE-MERGE pods**, `startTime 2026-10-04T07:11Z` |
|
||||||
|
| exported flow lines in agent stdout | **0, 0, 0** |
|
||||||
|
| Flux Kustomizations | all `Ready=True` |
|
||||||
|
|
||||||
|
Cause: the chart's `rollOutCiliumPods` defaults to **`false`** (cilium 1.20.1 `values.yaml`: *"Roll
|
||||||
|
out cilium agent pods automatically when configmap is updated"*), so Helm rewrites the ConfigMap and
|
||||||
|
leaves the DaemonSet's pod template untouched — and **cilium-agent reads that ConfigMap only at
|
||||||
|
startup**. The feature was merged, believed delivered, green everywhere, and doing nothing at all.
|
||||||
|
|
||||||
|
That is the same class of failure this repo already guards against elsewhere: a green signal sitting
|
||||||
|
over something that never ran.
|
||||||
|
|
||||||
|
- **`platform/cilium/helmrelease.yaml`** (authoritative): `rollOutCiliumPods: true`.
|
||||||
|
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||||
|
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||||
|
the same, so a bootstrap render does not differ from what Flux reconciles.
|
||||||
|
|
||||||
|
**The cost is real and is the point.** Every future Cilium config change now triggers a cluster-wide
|
||||||
|
agent rollout instead of waiting for someone to remember a manual restart. That is strictly more
|
||||||
|
honest — the change applies when merged, or it fails visibly — but it makes config edits disruptive
|
||||||
|
by default, so they want a quiet moment. Mitigation already in place:
|
||||||
|
`updateStrategy.rollingUpdate.maxUnavailable: 1` means the DaemonSet will not proceed past an unready
|
||||||
|
agent, so a bad render costs one node rather than the cluster.
|
||||||
|
|
||||||
|
**Merging this is itself one rollout**, because adding the ConfigMap checksum annotation changes the
|
||||||
|
pod template — and that rollout is what finally activates the Hubble exporter.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **Every row of the table above**, read from the live HelmRelease status, the live `cilium-config`
|
||||||
|
ConfigMap, the agent pods' `startTime`, and a grep for `"flow"` in each agent's stdout.
|
||||||
|
- **The chart value name, default and semantics**, quoted from cilium **v1.20.1**'s own
|
||||||
|
`values.yaml` — the version actually running here (`quay.io/cilium/cilium:v1.20.1`), not a guess
|
||||||
|
from another release: `rollOutCiliumPods: false` with the comment *"Roll out cilium agent pods
|
||||||
|
automatically when configmap is updated."*
|
||||||
|
- **All three value files carry the key** and both YAML files still parse, with
|
||||||
|
`spec.values.rollOutCiliumPods` resolving to `true`.
|
||||||
|
- **The blast-radius mitigation is genuinely present**, not assumed:
|
||||||
|
`updateStrategy.rollingUpdate.maxUnavailable: 1` is already set in the HelmRelease, with a long
|
||||||
|
existing comment explaining that a CNI rollout has no healthy-peer fallback.
|
||||||
|
- **THE MECHANISM IS CONFIRMED BY CONTRAST, not just reasoned.** The cgroup fix (PR #42,
|
||||||
|
2026-10-03) DID take effect: both the DaemonSet and the running pod mount
|
||||||
|
`cilium-cgroup hostPath=/sys/fs/cgroup`, matching what the HelmRelease declares. The difference is
|
||||||
|
exactly the one claimed above — #42 changed the POD TEMPLATE (a volume hostPath), which rolls the
|
||||||
|
DaemonSet on its own, while #50 changed ONLY the ConfigMap, which does not. It also explains the
|
||||||
|
agents' `startTime 2026-10-04T07:11Z`: that was #42's rollout, and nothing has rolled them since.
|
||||||
|
So the inertness is specific to ConfigMap-only changes, which is precisely what this flag fixes.
|
||||||
|
- **Flux is healthy before this change** — every Kustomization `Ready=True`, so this lands on a clean
|
||||||
|
graph rather than into an existing cascade.
|
||||||
|
- **Toolbox gate**: recorded in the PR.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE OUTCOME, ENTIRELY. Unreconciled.** No agent has rolled, no flow has been exported. This
|
||||||
|
change is an argument that the rollout will happen, not evidence that it did.
|
||||||
|
- **Whether `filePath: stdout` actually works** — still the open question from #50, and it cannot be
|
||||||
|
answered until the agents restart. If the literal is not special-cased, the agent may create a
|
||||||
|
FILE named `stdout` and nothing reaches Loki: a silent no-op, not a crash. Fallback documented in
|
||||||
|
#50: keep the chart's default path and add a `loki.source.file` target to Alloy with a hostPath
|
||||||
|
mount. **Check for flow lines in the agent logs immediately after the rollout.**
|
||||||
|
- **THE ROLLOUT'S IMPACT ON LAN DNS IS UNMEASURED AND IS THE MAIN RISK.** The node holding the L2
|
||||||
|
lease for the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP loses them briefly as it cycles.
|
||||||
|
`maxUnavailable: 1` bounds it to one node at a time, and BPF maps persist across an agent restart,
|
||||||
|
but I have not measured how long a VIP is unreachable during a lease move, nor whether the two
|
||||||
|
PiHole replicas cover the gap. Merge it at a quiet moment.
|
||||||
|
- **Whether enabling this is the operator's preferred trade.** The alternative — leaving it `false`
|
||||||
|
and restarting by hand — keeps config edits non-disruptive at the cost of them being silently
|
||||||
|
inert. I judged silent inertness the worse failure, but it is a judgement, not a measurement.
|
||||||
|
- **Whether anything ELSE in `cilium-config` is currently inert.** I checked the one other recent
|
||||||
|
Cilium change and it is fine (see below), but I did not audit every key against the running
|
||||||
|
agents' actual state.
|
||||||
|
- **`hubble.relay.rollOutPods` / `hubble.ui.rollOutPods`** are left at their `false` defaults. Both
|
||||||
|
have the same silently-inert property for their own ConfigMaps; not addressed here.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: docs/oauth2-sidecar-scrape-verified
|
||||||
|
title: The gate-sidecar scrape is verified working on prod — and four places in the tree still said it wasn't
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Documentation only. No manifest, no rendered output, no behaviour.
|
||||||
|
|
||||||
|
PR #49 merged as `21c88b0` and the fix works, but four places in the tree still asserted the
|
||||||
|
*pre-fix* state as current — in the present tense, in capitals, in the files a reader would consult
|
||||||
|
first. This is the stale-documentation failure mode this repo has been burned by twice already
|
||||||
|
(`git push origin main` in the runbooks, "append to DECISIONS-LOG.md" in the old handoffs), so:
|
||||||
|
|
||||||
|
- **`platform/oauth2-proxy/podmonitor.yaml`** — the banner read *"READ THIS BEFORE BELIEVING THE
|
||||||
|
TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY"* and *"SO THE TARGETS BELOW WILL
|
||||||
|
READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS"*. Both now false, and the first one actively
|
||||||
|
instructs the reader to disbelieve a correct `up == 1`. Replaced with a `STATUS: THE SCRAPE WORKS`
|
||||||
|
block carrying the post-fix numbers; the diagnosis is kept below it under an explicit `HISTORY`
|
||||||
|
heading, because it is why the fix is shaped the way it is. Added the drop fingerprint to
|
||||||
|
recognise if it regresses — `lastScrapeDuration` pinned at *exactly* the `scrapeTimeout` with
|
||||||
|
`context deadline exceeded`, versus 1–6 ms when healthy.
|
||||||
|
- **`catalog/render.py:159`** — *"THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED … Do not
|
||||||
|
read this flag as 'metrics are working'."* Rewritten to enumerate the three pieces a working
|
||||||
|
scrape needs and which PR landed each. The warning is **kept, deliberately**: the flag alone
|
||||||
|
really is insufficient, and "1 without 3" is exactly what caused the 63.7h outage. Only the
|
||||||
|
"not yet scraped" claim was false.
|
||||||
|
- **`catalog/render.py:1365`** — the sidecar arg comment, same correction, one line.
|
||||||
|
- **`platform/oauth2-proxy/README.md`** — its two observability subsections are *dated*
|
||||||
|
("measured 2026-09-30", "Update 2026-10-01"), so they are honest records rather than lies. But a
|
||||||
|
reader hits the stale body long before any correction, so a `> SUPERSEDED` pointer now sits
|
||||||
|
directly under the heading, and an `### Update 2026-10-05` section at the end carries the
|
||||||
|
before/after table, the TSDB series counts and the alert outcome.
|
||||||
|
|
||||||
|
Two substantive errors fixed while there, not just tense:
|
||||||
|
|
||||||
|
1. The README's *"To close the gap for an app, three things are needed"* listed **a Service port
|
||||||
|
and a ServiceMonitor**. Wrong — a PodMonitor targets pod ports directly, which is the whole
|
||||||
|
reason no Service exposes 44180 and the renderer needed no Service change. Following that
|
||||||
|
paragraph would have sent someone to edit `catalog/render.py`'s Service rendering for no reason.
|
||||||
|
2. `podmonitor.yaml` ended by deferring the fix to *"the catalog/renderer, which is in flight on
|
||||||
|
another branch"* — that branch does not exist and never did; the work happened in PR #49. It now
|
||||||
|
names `r_monitoring_ingress_policy` directly, and a new `CRAFTY` section explains why crafty is
|
||||||
|
the one namespace this file cannot help.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **The fix itself, on prod 2026-10-05, read-only** (this is the evidence the docs now quote):
|
||||||
|
Flux applied `21c88b0` through the real path — `apps-wave-0` `lastAppliedRevision` at 21:37:01Z,
|
||||||
|
`apps-wave-1` at 21:37:46Z, in dependency order — and all five `<app>-allow-monitoring-scrape`
|
||||||
|
CNPs exist, each `ports=['44180']`, `from=[['monitoring']]`.
|
||||||
|
- **All 6 targets `up == 1`**, `health=up`, `lastError=''`, `lastScrapeDuration` 0.001–0.006s —
|
||||||
|
against 10.0s and `context deadline exceeded` before.
|
||||||
|
- **`oauth2_proxy_*` is in the TSDB for these namespaces for the first time:** 5 metric names,
|
||||||
|
148 series; `sum by (namespace) (oauth2_proxy_requests_total)` = qbittorrent 35072, radarr 23560,
|
||||||
|
sonarr 1284. (Queried with `count by (__name__)` plus a matcher, not label-values.)
|
||||||
|
- **Alerts:** all 5 `TargetDown` and 5 of 6 `OAuth2ProxyGateUnobserved` resolved; one series
|
||||||
|
remains, `OAuth2ProxyGateUnobserved{namespace="crafty"}`, firing.
|
||||||
|
- **The rendered output is byte-identical.** `catalog/render.py` changed only in comments, so this
|
||||||
|
branch touches no `apps/` file and no golden. Confirmed by `render-golden` and `l1` both passing
|
||||||
|
with no regeneration, and by `git status` showing nothing under `apps/`.
|
||||||
|
- **This branch touches no sensitive path**, unlike PR #49: `platform/oauth2-proxy/**`,
|
||||||
|
`catalog/**` and `decisions/**` are none of them in `infra/gitea/approval-policy.json`. So it
|
||||||
|
should merge on a green gate with no human approval — the opposite of the PR it documents, and
|
||||||
|
the reason the two were kept apart.
|
||||||
|
- `render.py --selftest`: PASS.
|
||||||
|
- Toolbox gate: see the handoff's own rule — reported below rather than claimed here.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **I did not re-measure the 63.7h figure or the ~22 emails/day rate**; both are carried forward
|
||||||
|
from the 2026-10-04 measurements, now quoted inside the docs as historical. The 15-day
|
||||||
|
`query_range` that established "one episode, not flapping" was run once, on 2026-10-04.
|
||||||
|
- **`oauth2_proxy_requests_total` is still absent for jackett and pihole.** I assert in the README
|
||||||
|
that this is because the counter is not created until the gate serves its first request. That is
|
||||||
|
the documented behaviour of a Prometheus counter with no initialisation, and it is consistent
|
||||||
|
with the other three namespaces having traffic — but I did not force a request through jackett's
|
||||||
|
gate and watch the series appear. If it is still absent after real use, that is a finding.
|
||||||
|
- **The auth-churn rule is still unwritten.** The README now says it is "writable rather than
|
||||||
|
decorative"; that is a claim about the metrics being present, not a rule anyone has authored or
|
||||||
|
unit-tested. The thing the 2026-09-30 incident actually needed does not exist yet.
|
||||||
|
- **crafty remains unobserved.** I read its container spec on 2026-10-05 and confirmed no
|
||||||
|
`--metrics-address`; I did not attempt the fix, and nothing here changes its alert.
|
||||||
|
- **I did not audit the whole tree for other stale "metrics are not scraped" claims** beyond the
|
||||||
|
four sites a targeted grep found (`not yet reachable`, `UNTIL AN INGRESS`, `up == 0`,
|
||||||
|
`not reachable by Prometheus` and neighbours). `runbooks/oauth2-proxy-auth-failures.md` is
|
||||||
|
described in the README as "the by-hand substitute meanwhile" and I left it alone — the by-hand
|
||||||
|
log procedure still works and is not wrong, but whether it should now be rewritten against the
|
||||||
|
new metrics is unexamined.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: fix/plex-resolver-independent-advertise
|
||||||
|
title: Plex down LAN-wide — the advertised name existed only in PiHole, so clients that bypass it landed on the router's %D cert
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
**I took Plex down for the whole LAN.** Reported symptom: every internal client showed a certificate
|
||||||
|
error with `%D` as the subject name and could not connect.
|
||||||
|
|
||||||
|
**Root cause: the advertised connection depended on our own resolver.** `ADVERTISE_IP` was
|
||||||
|
`http://plex-vip.${BASE_DOMAIN}:32400` — a name that exists ONLY in PiHole's split-horizon answers
|
||||||
|
and in no public zone. A Plex client resolves the advertised host *itself*, with whatever resolver it
|
||||||
|
happens to use, and on a real LAN that is frequently not PiHole: browsers ship DoH on by default,
|
||||||
|
phones have "private DNS", guest/IoT devices take the router's DNS. Those clients could not resolve
|
||||||
|
the name, the SOHO router answered the failed lookup with **itself**, and they all landed on the
|
||||||
|
router's web UI.
|
||||||
|
|
||||||
|
| Measured 2026-10-05 | Result |
|
||||||
|
|---|---|
|
||||||
|
| `192.168.2.1:443` (router, LAN side) | `C = ZZ, ST = Somewhere, O = %D342e7e6b, CN = %D` |
|
||||||
|
| `136.51.115.252:443` (router, WAN side) | the same `%D` cert |
|
||||||
|
| PiHole **pihole-0** | `plex -> 192.168.2.20`, `plex-vip -> 192.168.2.24` ✅ |
|
||||||
|
| PiHole **pihole-1** | identical ✅ (both behind the `.23` VIP) |
|
||||||
|
| Gateway `.20:443` cert | `CN = *.home.keithjackson.dev`, valid to 2026-12-12 ✅ |
|
||||||
|
| `http://192.168.2.24:32400/identity` | `200` ✅ |
|
||||||
|
| Plex's published connections | all port **32400** — nothing on 443 |
|
||||||
|
|
||||||
|
**The DNS was right; the dependency on it was the bug.** PiHole answered correctly from both
|
||||||
|
replicas the entire time. And because nothing Plex advertises listens on 443, the clients had plainly
|
||||||
|
left the Plex path altogether and were talking to the router.
|
||||||
|
|
||||||
|
**The fix: go back to `<dashed-ip>.<serverhash>.plex.direct`, which is RESOLVER-INDEPENDENT.** This
|
||||||
|
is Plex's own mechanism and the reason it exists — `plex.direct` is a public zone that returns the
|
||||||
|
private address embedded in the label, so *every* resolver (PiHole, the router, 1.1.1.1, a DoH
|
||||||
|
browser) answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no split-horizon
|
||||||
|
entry, no Cloudflare record and no certificate work, because the wildcard cert Plex already serves on
|
||||||
|
32400 covers exactly that name.
|
||||||
|
|
||||||
|
- **`catalog/plex.yaml`:** `ADVERTISE_IP` →
|
||||||
|
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`, with the
|
||||||
|
resolver-independence requirement written at the top of the block as the first thing to check
|
||||||
|
before anyone changes this field again.
|
||||||
|
- **PiHole's `plex-vip` answer is KEPT.** It is harmless, costs nothing, and is useful for reaching
|
||||||
|
the VIP by name from a LAN shell. It is simply not what Plex advertises.
|
||||||
|
|
||||||
|
**Why not keep the nicer hostname.** It cannot be made correct for clients that bypass PiHole: a
|
||||||
|
public record for it resolves to the WAN address, and with no NAT hairpin on this router a LAN client
|
||||||
|
sent there fails too. The name is right only for PiHole users and wrong for everyone else, and there
|
||||||
|
is no configuration of it that is right for both. `plex.direct` is right for both.
|
||||||
|
|
||||||
|
**The honest cost, restated rather than buried:** the value embeds both the VIP and the server's
|
||||||
|
certificate hash as a literal — envsubst cannot dash an IP and nothing in the tree can derive the
|
||||||
|
hash, so nothing checks that they still agree. That fragility is exactly why the hostname was tried.
|
||||||
|
It is accepted because an unresolvable name is a *worse* failure: silent, LAN-wide, and it points
|
||||||
|
users at their router.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, 2026-10-05, read-only.
|
||||||
|
|
||||||
|
- **Every row of the table above**, including the router's `%D` cert on both its LAN and WAN sides —
|
||||||
|
which is what identifies the router as what the clients were reaching.
|
||||||
|
- **THE FIX ITSELF, with full TLS validation (no `curl -k`)**: the plex.direct name resolves to
|
||||||
|
`192.168.2.24` and `/identity` returns **200, TLS verified**. Re-checked today, not carried over
|
||||||
|
from yesterday's measurement.
|
||||||
|
- **Both PiHole replicas are identical and correct**, with the same five `address=` lines live in
|
||||||
|
`/etc/pihole/dnsmasq.conf`, and both IPs present behind the `pihole-dns-tcp`/`-udp` endpoints. I
|
||||||
|
checked only pihole-0 at first and deliberately went back for pihole-1, because a split-brain pair
|
||||||
|
would have produced exactly this intermittent-looking symptom.
|
||||||
|
- **The cluster was never the problem:** all eight LoadBalancer VIPs still assigned, L2
|
||||||
|
announcements intact (prod-3 holds `.20`/`.23`/`.24`), `wildcard-home` Certificate `Ready=True`,
|
||||||
|
and PR #51 had reconciled (`customConnections="http://plex-vip.home.keithjackson.dev:32400"` was
|
||||||
|
live, confirming the bad value was in effect).
|
||||||
|
- **`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is still set**, so the postStart hook survived
|
||||||
|
the roll and keeps re-asserting.
|
||||||
|
- **The Cilium rollout from #50 STILL has not happened** — agents are unchanged since
|
||||||
|
2026-10-04T07:11, so the Hubble export is inactive and `filePath: stdout` remains unproven.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE OUTCOME. Unreconciled.** No client has been observed connecting after this change. The
|
||||||
|
argument is that the advertised name now resolves for every resolver and serves a matching
|
||||||
|
certificate — both measured — but I have not seen a Plex client complete a connection, and I
|
||||||
|
cannot see the user's clients at all.
|
||||||
|
- **THE CLIENT-SIDE RESOLVER PATH IS INFERRED, NOT OBSERVED.** I could not query a public resolver
|
||||||
|
from here — my attempt returned NXDOMAIN even for a `plex.direct` name that certainly resolves, so
|
||||||
|
the probe was broken (egress policy or a missing tool) and I discarded it rather than report it.
|
||||||
|
The chain "client bypasses PiHole → NXDOMAIN → router answers with itself → `%D`" is the only
|
||||||
|
explanation consistent with all the measurements, but the NXDOMAIN-hijack step in particular is
|
||||||
|
deduced from the router serving `%D` on its LAN address, not from watching a lookup.
|
||||||
|
- **Which clients bypass PiHole, or how many.** If in fact every client uses PiHole, then my
|
||||||
|
explanation is wrong and something else sent them to the router — in which case the next thing to
|
||||||
|
check is whether the clients were using `plex.home.keithjackson.dev` (port 443, the Gateway) rather
|
||||||
|
than an advertised connection at all.
|
||||||
|
- **Whether plex.tv will re-publish promptly** after the value changes, and whether stale connection
|
||||||
|
lists are cached on clients. A client holding the old list may keep failing until it refreshes.
|
||||||
|
- **Mixed content.** The previous `http://` value would also have been blocked by the browser for
|
||||||
|
anyone using `https://app.plex.tv`; that is fixed incidentally by returning to `https`, but I did
|
||||||
|
not test it.
|
||||||
|
- **The Remote badge — the symptom that began this whole sequence — remains unconfirmed.**
|
||||||
|
`LanNetworksBandwidth` is set, but Plex still tags traffic `Allowed Network (WAN)`. Unchanged and
|
||||||
|
still unexplained.
|
||||||
|
- **`ManualPortMappingMode="1"` with an absent port** is still there, untouched.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: feat/vpn-remote-access-wireguard
|
||||||
|
title: Remote-access VPN — WireGuard on udp/51820, hostNetwork because a pod identity would 403 every Gateway hostname
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
New platform component `platform/vpn-ingress/`: a **WireGuard** remote-access VPN so the operator's
|
||||||
|
Android / Linux / Windows / macOS devices can dial in from anywhere on the internet and behave like
|
||||||
|
machines on the LAN — full reach to `192.168.2.0/24` plus internet egress out the house WAN.
|
||||||
|
|
||||||
|
This is the **opposite direction** to the existing `platform/vpn-egress` (pods *out* through PIA,
|
||||||
|
ADR-0015). They share nothing but the word "VPN"; both READMEs now say so, and `README.md`'s layout
|
||||||
|
block labels the two.
|
||||||
|
|
||||||
|
**Transport: WireGuard, and the security argument is the handshake's failure mode, not "it is
|
||||||
|
encrypted".** WireGuard's first handshake message carries a MAC keyed with the responder's own
|
||||||
|
public key, so a sender that does not already know it gets **no reply at all** — no error, no reset,
|
||||||
|
no banner. From the internet, udp/51820 is indistinguishable from a closed port: nothing to
|
||||||
|
brute-force, no version string, no amplification. That is what makes an unfiltered WAN port
|
||||||
|
defensible here, and it is why there is deliberately no `loadBalancerSourceRanges`-style allowlist
|
||||||
|
(which would also break roaming clients). It also has first-party clients on all four named
|
||||||
|
platforms, which OpenVPN (needs a CA + per-device certs + a CRL) and IKEv2 do not.
|
||||||
|
|
||||||
|
**`hostNetwork: true`, and this is THE decision — forced by a measured property of this cluster.**
|
||||||
|
A pod-networked WireGuard server must SNAT client traffic to its own pod IP (Cilium drops a packet
|
||||||
|
whose source is not the endpoint's address), so every remote client would have arrived wearing *that
|
||||||
|
pod's* security identity. `decisions/2026-10-04-plex-advertise-plexdirect.md` measured what that
|
||||||
|
costs: a request to the shared Gateway VIP **from inside a pod returns `403 Access denied` from
|
||||||
|
Envoy for all 16 Gateway hostnames**, because Cilium's Gateway preserves the original source
|
||||||
|
identity and enforces the *destination's* ingress policy, and every app admits `reserved:ingress`
|
||||||
|
plus named peers — never an arbitrary pod. So the obvious build would have produced a tunnel that
|
||||||
|
came up healthy, showed connected peers, routed packets, and 403'd every cluster web UI — and
|
||||||
|
`*.home.keithjackson.dev` would not even have resolved, because `apps/wave-0/pihole-dns.yaml`'s
|
||||||
|
ingress policy deliberately omits the `cluster` entity. In the host netns there is no pod identity
|
||||||
|
to carry: forwarded traffic is SNATed to the node address and arrives as `host`/`remote-node`,
|
||||||
|
byte-for-byte what a LAN client looks like. Three further problems vanish with it: `ip_forward` is
|
||||||
|
already 1 on these nodes (so no `--allowed-unsafe-sysctls` kubelet change — it is *not* a safe
|
||||||
|
sysctl); no LoadBalancer VIP, so no L2-announcement lease lottery and no `externalTrafficPolicy`
|
||||||
|
trade-off; and no double encapsulation, which is what makes `MTU = 1420` simply correct.
|
||||||
|
|
||||||
|
`hostNetwork` is permitted by `restrict-host-namespaces`' own stated carve-out (it matches
|
||||||
|
`app-namespace=true` only, and names Cilium/node-exporter as the precedent); `namespace.yaml` is
|
||||||
|
deliberately unlabelled. **The cost is stated, not buried:** a hostNetwork pod has no Cilium
|
||||||
|
endpoint, so **no NetworkPolicy constrains this workload**. `networkpolicy.yaml` says that at
|
||||||
|
length rather than shipping an `allow world → 51820` rule that would read as the control and
|
||||||
|
enforce nothing; the real boundary is WireGuard's peer authentication.
|
||||||
|
|
||||||
|
**A DaemonSet, not a Deployment.** The WAN forward names one node address by hand, off-repo (same
|
||||||
|
shape as the plex tcp/32400 forward). A floating single replica would silently invalidate it on
|
||||||
|
every reschedule; an identical listener on all three nodes makes **failover a one-line router
|
||||||
|
change** with no cluster work and no re-issued client profiles. A pinned LoadBalancer VIP was the
|
||||||
|
considered alternative and was rejected: it reintroduces the L2 lease, SNAT and an extra node hop
|
||||||
|
into the one path whose failure mode is "I am away from home and cannot get in".
|
||||||
|
|
||||||
|
**Authorisation is split so that it stays reviewable.** Peer **public** keys live in
|
||||||
|
`platform/vpn-ingress/peers.yaml` in **plaintext** — a public key grants nothing by itself, and this
|
||||||
|
makes "who can get into my house" a PR diff with `git log` as its audit trail. The only real secret,
|
||||||
|
the server's private key, is the `wg-server-key` Secret, **created out of band by the operator**: an
|
||||||
|
agent must not mint a long-lived private key (CLAUDE.md §6), and a key that passed through a session
|
||||||
|
transcript would have to be treated as compromised. The two halves are joined only at pod start, by
|
||||||
|
an init container, into a `medium: Memory` emptyDir — so the assembled config holding the key never
|
||||||
|
touches a node's disk and dies with the pod.
|
||||||
|
|
||||||
|
**There are no peers yet, and that is the correct initial state**, for the same reason. The VPN ships
|
||||||
|
INERT-BUT-HEALTHY: a zero-peer interface listens and refuses every handshake.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `platform/vpn-ingress/` (new): `namespace.yaml`, `peers.yaml` (the plaintext peer register),
|
||||||
|
`daemonset.yaml` (hostNetwork listener + config assembly), `networkpolicy.yaml` (namespace
|
||||||
|
baseline, with the honest caveat), `kustomization.yaml`, `README.md` (the design record).
|
||||||
|
- `clusters/base/vpn-ingress.yaml` (new): its own **non-gating post-platform** Flux Kustomization
|
||||||
|
(`dependsOn platform-config`, `wait:false`). Non-gating is load-bearing rather than conventional
|
||||||
|
here: the DaemonSet *cannot* start until the operator creates the server key, so in the gating
|
||||||
|
`platform` tier an unfinished manual step would have stalled every apps wave on merge.
|
||||||
|
- `clusters/base/kustomization.yaml`: the new tier added.
|
||||||
|
- `clusters/base/cluster-vars.yaml`: new `WG_CLIENT_CIDR` (`10.90.0.0/24`), declared next to
|
||||||
|
`LAN_CIDR` because it is the one range that must be collision-checked against every other; the
|
||||||
|
comment enumerates the five it avoids. **No prod override, deliberately** — unlike every VIP, this
|
||||||
|
range is private to the tunnel, so the same value is correct in both environments and a prod patch
|
||||||
|
would imply otherwise. Substitution inventory updated.
|
||||||
|
- `infra/ansible/roles/node_baseline/defaults/main.yml`: `wireguard` added to
|
||||||
|
`node_baseline_kernel_modules`. The pod has `NET_ADMIN` but deliberately **not** `SYS_MODULE`, so
|
||||||
|
it cannot autoload the module — it CrashLoops with a clear error rather than quietly degrading to
|
||||||
|
userspace wireguard-go (which would need `/dev/net/tun`, a device a hostPath mount cannot actually
|
||||||
|
grant, as the `/dev/dri` attempt proved).
|
||||||
|
- `infra/vpn-ingress/new-peer.sh` (new): enrols a device. Generates the keypair on the operator's
|
||||||
|
machine, writes a 0600 profile **outside** the checkout (it refuses to write into the repo),
|
||||||
|
prints a QR code for phones, and prints only the public `[Peer]` stanza. It also refuses a
|
||||||
|
duplicate address, because two devices on one address does not error anywhere — the server routes
|
||||||
|
to whichever handshook last, so both appear to work intermittently.
|
||||||
|
- `runbooks/vpn-remote-access.md` (new) + its `kustomization.yaml` generator entry + `README.md`
|
||||||
|
index line: the three manual steps, the four off-LAN verification checks, revocation, and why no
|
||||||
|
connection alert exists.
|
||||||
|
- `README.md`: `vpn-ingress` added to the layout, with the egress/ingress distinction spelled out.
|
||||||
|
|
||||||
|
**THREE MANUAL STEPS REMAIN AND THE VPN DOES NOT WORK UNTIL ALL THREE ARE DONE** (all off-repo by
|
||||||
|
necessity; `runbooks/vpn-remote-access.md`): run `node_baseline` to load the kernel module, create
|
||||||
|
the `wg-server-key` Secret, and forward WAN udp/51820 to one node address on the SOHO router.
|
||||||
|
|
||||||
|
**SENSITIVE PATHS — this PR touches `clusters/**` (three files), so `ci/approval-policy.sh` will
|
||||||
|
BLOCK it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
|
||||||
|
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
|
||||||
|
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, read-only, 2026-10-05, plus the offline gate.
|
||||||
|
|
||||||
|
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||||
|
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||||
|
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
|
||||||
|
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
|
||||||
|
Run from an isolated git worktree with both CLAUDE.md §3 workarounds applied
|
||||||
|
(`DEV_KUBECONFIG=/dev/null` plus an `ENGINE` shim bind-mounting the main checkout's `.git` and
|
||||||
|
the sibling spec repo) — which is why `spec-immutability` is **green here and skipped in CI**,
|
||||||
|
where the runner has no spec checkout. `approval-policy` passing locally is an offline N/A, not a
|
||||||
|
verdict; the real verdict is the CI run below.
|
||||||
|
- **The CI gate ran on PR #55 (run 244, head `1a67fc1b`) and `approval-policy` is the ONLY failing
|
||||||
|
stage** — read from the job log, not from the red tick. Verbatim: "This PR changes the pipeline or
|
||||||
|
the security posture it rests on, and has NO approving review from anyone other than its author
|
||||||
|
('gitea-admin')", naming exactly the three files I predicted:
|
||||||
|
`clusters/base/cluster-vars.yaml`, `clusters/base/kustomization.yaml`,
|
||||||
|
`clusters/base/vpn-ingress.yaml`. Every other stage that ran reported green —
|
||||||
|
`gitea-settings`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`. So the branch is blocked
|
||||||
|
on the operator's `/approve-override <reason>` (posted as a PR **review** of type "Comment") and
|
||||||
|
on nothing else. **I have not posted it** — CLAUDE.md §1.
|
||||||
|
- **Branch protection is live, measured by CI's own `gitea-settings` stage** (it has the token this
|
||||||
|
workstation lacks): `required_approvals = 0`, `enable_status_check = true`,
|
||||||
|
`status_check_contexts = ["gate / gate (pull_request)"]`, `block_admin_merge_override = true`,
|
||||||
|
`enable_push = false`, `dismiss_stale_approvals = true`, and **1 runner online**. That is the §2b
|
||||||
|
re-measurement of CLAUDE.md §2's table for this branch, not a restatement of it.
|
||||||
|
- **`flux-postbuild-substitution` (L0) actually exercised the new wiring, and caught a real defect
|
||||||
|
in it.** On the first run it FAILED, naming `platform/vpn-ingress/daemonset.yaml:118` and
|
||||||
|
attributing it to "Flux Kustomization 'vpn-ingress' (clusters/base/vpn-ingress.yaml, path
|
||||||
|
./platform/vpn-ingress)". Two things are proven by that message: the new tier's
|
||||||
|
`postBuild.substituteFrom` declaration is correctly detected (so `${WG_CLIENT_CIDR}` will be
|
||||||
|
substituted, not shipped literally), and the check reads comments as well as values — the hazard
|
||||||
|
was a comment that spelled out the forbidden braced form literally while claiming not to use it.
|
||||||
|
Fixed; the check now passes 444/444 with 0 hazards.
|
||||||
|
- **The `wireguard` kernel module is available on these nodes by construction**: all three run
|
||||||
|
Debian 13 (trixie) on kernel `7.1.8+deb13-amd64` (measured), and WireGuard has been in-tree since
|
||||||
|
5.6, so it is present in the distro kernel and only needs loading.
|
||||||
|
- **`net.ipv4.ip_forward = 1` is already written by `node_baseline`**
|
||||||
|
(`roles/node_baseline/tasks/main.yml`, `/etc/sysctl.d/99-k3s-cilium.conf`), and **no kubelet
|
||||||
|
carries `--allowed-unsafe-sysctls`** (grepped across `roles/k3s_server`, `roles/node_baseline` and
|
||||||
|
`group_vars`: zero hits). That pair is what makes the host netns the only option that does not
|
||||||
|
require a node-restart change.
|
||||||
|
- **Cilium's datapath, measured from `cilium-config` rather than from the values file**:
|
||||||
|
`enable-ipv4-masquerade=true`, `enable-bpf-masquerade=true`, `routing-mode=tunnel`,
|
||||||
|
`tunnel-protocol=vxlan`, `ipv4-native-routing-cidr` EMPTY, Cilium `v1.20.1`. The vxlan underlay is
|
||||||
|
where `MTU = 1420` comes from, and the empty native-routing CIDR is why pod→LAN is masqueraded
|
||||||
|
today.
|
||||||
|
- **`WG_CLIENT_CIDR` collides with nothing.** Pod CIDR `10.0.0.0/16` (`CLUSTER_POD_CIDR`), service
|
||||||
|
CIDR `10.43.0.0/16` (measured: `kubernetes` Service clusterIP is `10.43.0.1`), the PIA tunnel's
|
||||||
|
`10.72.192.0/18` (`platform/vpn-egress/README.md`), and both LANs `192.168.1.0/24` /
|
||||||
|
`192.168.2.0/24`. `10.90.0.0/24` is outside all five.
|
||||||
|
- **The egress-class label matters and is set to `internet`.** Read live:
|
||||||
|
`CiliumEgressGatewayPolicy vpn-egress-steering` selects `podSelector:
|
||||||
|
homelab.keithjackson.dev/egress-class: vpn` with **no namespace scope**, so `vpn` here would have
|
||||||
|
steered every remote client's internet traffic out PIA.
|
||||||
|
- **All three nodes are untainted** (`spec.taints: None` on each), so the DaemonSet covers the
|
||||||
|
cluster with no tolerations.
|
||||||
|
- **`new-peer.sh` was run end to end** against a throwaway server key in the scratchpad (deleted
|
||||||
|
immediately after; it never existed in the repo). It rendered a correct profile — `Address
|
||||||
|
10.90.0.7/32`, `DNS 192.168.2.23`, `Endpoint wan.home.keithjackson.dev:51820`, `MTU 1420`,
|
||||||
|
`PersistentKeepalive 25`, `AllowedIPs 0.0.0.0/0` — at mode 0600, printed the matching public-key
|
||||||
|
stanza, and both argument-validation paths (missing name, out-of-range octet) refuse correctly.
|
||||||
|
The private key was redacted from every line I displayed.
|
||||||
|
- **The endpoint name is resolver-independent, which is the specific trap this avoids.**
|
||||||
|
`wan.${BASE_DOMAIN}` is an unproxied public Cloudflare A record kept current by
|
||||||
|
`platform/externaldns/ddns.yaml` (checked every 5 min), so it resolves from a cellular connection
|
||||||
|
— unlike any name PiHole answers, which a remote client cannot resolve until it is already
|
||||||
|
connected. (This is the `plex.direct` lesson applied.)
|
||||||
|
- **Velero does cover this namespace.** `platform/velero/helmrelease.yaml`'s `daily` schedule
|
||||||
|
excludes only `monitoring` and `unifi`, so `vpn-ingress` and the `wg-server-key` Secret are
|
||||||
|
captured off-cluster to R2. Read from the manifest, not from a backup listing.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **THE ACTUAL FIX. Nothing in this branch has been proven to carry a single packet.** The
|
||||||
|
DaemonSet has never been reconciled, no `wg0` has ever existed, and no handshake has ever been
|
||||||
|
attempted. Three blockers are off-repo and manual (kernel module, server key, WAN forward), and
|
||||||
|
this branch removes none of them — it only makes them the *only* remaining work.
|
||||||
|
- **Outside-in reachability, at all.** This repo has **no off-LAN vantage point**, which is exactly
|
||||||
|
the gap the plex remote-access work hit. Step 6 of the runbook (a phone on cellular) is the only
|
||||||
|
honest test and it has not been run.
|
||||||
|
- **The in-pod `iptables` → host-netfilter interaction, which is my biggest single doubt.** Debian 13
|
||||||
|
uses nf_tables; the container writes MASQUERADE/FORWARD rules into the **host's** tables via the
|
||||||
|
image's own `iptables`. If that binary is the legacy backend while the host is nft, the rules can
|
||||||
|
apply to a table the kernel does not consult — and the symptom would be the nastiest available:
|
||||||
|
handshakes succeed, `wg show` looks perfect, and every forwarded connection hangs. **Check this
|
||||||
|
first if the tunnel comes up but nothing is reachable:** `iptables-save -t nat | grep MASQUERADE`
|
||||||
|
and `nft list table ip nat` on the node, from the host, not from the pod.
|
||||||
|
- **Whether `allowPrivilegeEscalation: false` is compatible with the image's s6 init.** It is the
|
||||||
|
correct posture and I set it, but this repo has already been bitten by `no-new-privileges`
|
||||||
|
misbehaving (`platform/externaldns/ddns.yaml`'s README note). If the pod fails before
|
||||||
|
`wg-quick` runs, that flag is the first thing to try flipping.
|
||||||
|
- **Whether `NET_ADMIN` alone is sufficient.** Reasoned from what `wg-quick` does (create the link,
|
||||||
|
set a sysctl, write two netfilter rules), not measured. If `iptables` complains, `NET_RAW` is the
|
||||||
|
likely addition — deliberately not granted pre-emptively.
|
||||||
|
- **The image's custom-config path.** `lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124` (tag
|
||||||
|
confirmed to exist and to be the current one, published 2026-10-01) is expected to bring up
|
||||||
|
whatever is in `/config/wg_confs/` when `PEERS` is unset. That is its documented behaviour; it has
|
||||||
|
not been run here, and the init container's assembly of that file has never executed.
|
||||||
|
- **Whether the probe is right.** `wg show wg0` is asserted to exit non-zero when the interface is
|
||||||
|
absent. Not executed.
|
||||||
|
- **Cilium's reaction to a new `wg0` in the host netns.** `platform/cilium/values.yaml` does not pin
|
||||||
|
`devices`, so Cilium auto-detects; whether it attaches to `wg0` — and whether that matters — is
|
||||||
|
unknown. `wg` interfaces are already filtered out by `node_health_collect.py`, so something in
|
||||||
|
the estate anticipates them, but that is not evidence about Cilium.
|
||||||
|
- **Zero peers means the end-to-end authorisation path is untested**, including revocation. The
|
||||||
|
first enrolment is also the first test of `peers.yaml`'s format being what `wg-quick` accepts.
|
||||||
|
- **No peer-level observability exists.** An exporter publishing
|
||||||
|
`wireguard_latest_handshake_seconds` is the top follow-up; until then "who is connected" is an
|
||||||
|
on-demand `wg show` and nothing alerts on an unexpected session. Pod-down is covered only by
|
||||||
|
kube-prometheus-stack's generic `KubePodNotReady` / `KubeDeploymentReplicasMismatch`, which I did
|
||||||
|
not confirm fire for a DaemonSet in this namespace.
|
||||||
|
- **The security consequence I am most keen for a human to review rather than take from me:** an
|
||||||
|
enrolled device reaches the three k3s nodes themselves — apiserver 6443, kubelet 10250, SSH 22 —
|
||||||
|
because "reach the entire local network" includes them. That is stated in the README and the
|
||||||
|
runbook with the narrowing recipe, but it is a posture decision, not a verified fact, and it is
|
||||||
|
the right thing to argue about on this PR.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: fix/vpn-runbook-ansible-command
|
||||||
|
title: vpn-remote-access runbook — step 1 named a script that does not exist, and step 2 cited the wrong reason the key is gitignored
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Two defects in `runbooks/vpn-remote-access.md` (merged in #55), both authored by me, both reported
|
||||||
|
by the operator the first time they tried to follow it.
|
||||||
|
|
||||||
|
**1. Step 1 named a command that does not exist.** It said:
|
||||||
|
|
||||||
|
cd infra/ansible && ./site.sh --tags system --limit k3s_prod
|
||||||
|
|
||||||
|
There is **no `site.sh` anywhere in this repo** (`find . -name site.sh` → nothing). The invocation
|
||||||
|
was wrong in four separate ways at once, which is what makes it worth recording rather than just
|
||||||
|
patching: wrong kind of thing (this repo drives Ansible with `ansible-playbook <playbook>`, never a
|
||||||
|
wrapper script — `playbooks/site.yml` exists and is the full bring-up chain, not this); wrong
|
||||||
|
playbook (the module work lives in `playbooks/node-baseline.yml`); wrong targeting flag (`--limit`
|
||||||
|
instead of this repo's `-e k3s_hosts=...` convention, which `infra/ansible/README.md` "Targeting
|
||||||
|
prod" documents for every playbook); and no toolbox wrapper, though Ansible exists only inside the
|
||||||
|
pinned container and `ansible.cfg` is only read from `infra/ansible`.
|
||||||
|
|
||||||
|
Replaced with the real command, plus the three things about it that are load-bearing:
|
||||||
|
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
|
||||||
|
|
||||||
|
- `-e k3s_hosts=k3s_prod` — the playbook defaults to `k3s_servers`, which is the **dev** group;
|
||||||
|
dev is decommissioned, so an unqualified run does nothing useful.
|
||||||
|
- `--tags system`, never untagged — the two kernel-module tasks carry that tag, and an untagged run
|
||||||
|
would also hit the `disks` tasks, which assert on block-device variables prod deliberately leaves
|
||||||
|
as placeholders (the playbook's own header warns about this). `--tags system` is broader than the
|
||||||
|
modules alone (sysctls, iscsid, qemu-guest-agent, swap masking) but each is idempotent and
|
||||||
|
already in its desired state on a live node.
|
||||||
|
|
||||||
|
**2. Step 2 gave the wrong reason the server key is safe from git — and the right reason is
|
||||||
|
fragile.** It told the operator to stage the key at `/work/.cache/server.key` "inside the repo's
|
||||||
|
gitignored cache". `.cache/` is **not** gitignored: `git check-ignore -v .cache/serverkey.txt`
|
||||||
|
matches nothing. The file was ignored only incidentally, by `.gitignore` line 11 `*.key` — so the
|
||||||
|
path happened to be safe for a reason I had not identified, and **any operator who renamed it
|
||||||
|
(`server.pem`, `wg-server`, `privatekey`) would have had a committable WireGuard private key sitting
|
||||||
|
in the checkout.** The gate would not have caught it either; `no-plaintext-secrets` does not
|
||||||
|
recognise a bare WireGuard key.
|
||||||
|
|
||||||
|
The step now states the real rule, marks the `.key` suffix as the thing doing the work, and gives
|
||||||
|
the operator a verification they can run before trusting it (`git check-ignore -v` must print a
|
||||||
|
rule, and `git status --short` must not list the file).
|
||||||
|
|
||||||
|
Nothing outside `runbooks/vpn-remote-access.md` changed; no manifest, no behaviour.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **`site.sh` does not exist**: `find . -name 'site.sh' -not -path './.git/*'` → no output.
|
||||||
|
- **The real surface**: `infra/ansible/playbooks/` contains `node-baseline.yml` and `site.yml` (and
|
||||||
|
11 others); `node-baseline.yml`'s own header documents both
|
||||||
|
`-e k3s_hosts=k3s_prod` and the tag-scoping hazard. The `-e <playbook>_hosts=` convention is
|
||||||
|
confirmed by `infra/ansible/README.md` §"Targeting prod" and by the existing
|
||||||
|
`runbooks/node-memory-swap.md`, which invokes `ansible-playbook playbooks/node-health.yml -e
|
||||||
|
node_health_hosts=...`.
|
||||||
|
- **`k3s_prod` is a real inventory group**: `[k3s_prod:children]` in `inventory/hosts.ini`, with
|
||||||
|
`[k3s_prod_primary]`/`[k3s_prod_secondary]`.
|
||||||
|
- **`--tags system` covers the kernel-module tasks**: both "Persist kernel modules…" and "Load
|
||||||
|
kernel modules now" carry `tags: [system]` in `roles/node_baseline/tasks/main.yml`.
|
||||||
|
- **The gitignore claim, measured both directions**:
|
||||||
|
`git check-ignore -v .cache/server.key` → `.gitignore:11:*.key`;
|
||||||
|
`git check-ignore -v .cache/serverkey.txt` → no match. So the suffix is the only thing ignoring it.
|
||||||
|
- **Every path the component's docs reference resolves**: all six relative markdown links in
|
||||||
|
`platform/vpn-ingress/README.md` (checked from that file's own directory), and both Ansible
|
||||||
|
playbook paths.
|
||||||
|
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||||
|
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; the rc 3 is the two
|
||||||
|
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no `CLUSTER_AVAILABLE`).
|
||||||
|
**Not green. Reported as rc 3, not as pass.**
|
||||||
|
- **This branch touches NO sensitive path** (`runbooks/**` and `decisions/**` are not in
|
||||||
|
`infra/gitea/approval-policy.json`), so unlike #55 it needs no `/approve-override`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The corrected command has not been RUN.** Ansible is an operator action, and this is the
|
||||||
|
decisive gap: I have verified the playbook, the group, the tag and the flag all exist and are
|
||||||
|
the documented convention, but not that the run succeeds end to end or that
|
||||||
|
`lsmod | grep '^wireguard'` then reports the module. It is a better-evidenced command, not a
|
||||||
|
proven one.
|
||||||
|
- **Whether `--tags system` is entirely side-effect-free on a live prod node.** Each task it runs is
|
||||||
|
idempotent by construction, but I did not dry-run it (`--check`) to confirm it reports zero
|
||||||
|
changes beyond the module load.
|
||||||
|
- **The rest of the runbook's commands past step 2** are unchanged and still unexercised — in
|
||||||
|
particular `kubectl exec ds/wireguard -- wg show`, which cannot be reached while the pods sit in
|
||||||
|
`Init` waiting on the server key.
|
||||||
|
- **Whether any OTHER document I wrote in #55 contains an invented command.** I re-checked every
|
||||||
|
file path and markdown link in the component (all resolve) and every script the docs tell an
|
||||||
|
operator to run, but "the referenced file exists" is weaker than "the command is correct" — which
|
||||||
|
is exactly the gap that produced `site.sh`.
|
||||||
|
|
||||||
|
## Post-merge state of #55, recorded here since its own entry predates the merge
|
||||||
|
|
||||||
|
Measured on live prod after #55 merged as `ad3f56e`:
|
||||||
|
|
||||||
|
- Flux applied it by the real path: Kustomization `vpn-ingress` `lastAppliedRevision:
|
||||||
|
main@sha1:ad3f56e`, equal to the GitRepository's artifact revision, `Ready=True`.
|
||||||
|
- All **18** Flux Kustomizations `Ready=True` — the new tier disturbed nothing, and `wait: false`
|
||||||
|
behaved as intended (`vpn-ingress` is Ready while its own pods are not, so nothing downstream
|
||||||
|
stalled on it).
|
||||||
|
- **`hostNetwork` is confirmed in effect, not merely configured**: the three pods report pod IPs
|
||||||
|
`192.168.2.10/.11/.12` — the node addresses, one per node.
|
||||||
|
- **It fails closed exactly as designed**, with a legible cause: all three pods `Init:0/1`,
|
||||||
|
`MountVolume.SetUp failed for volume "wg-server-key" : secret "wg-server-key" not found`, and no
|
||||||
|
Secret in the namespace. The #55 entry's "the DaemonSet has never been reconciled" is superseded
|
||||||
|
by this paragraph; its remaining unverified claims (the tunnel carrying a packet, the
|
||||||
|
iptables/nftables backend question) still stand.
|
||||||
+2
-1
@@ -26,7 +26,8 @@ The fix is the obvious one for concurrent appenders: **stop sharing a file**. Ea
|
|||||||
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
|
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
|
||||||
nothing for git to merge and nothing to conflict. This scales to any number of parallel
|
nothing for git to merge and nothing to conflict. This scales to any number of parallel
|
||||||
branches with zero coordination — which is the point, since the agents are deliberately
|
branches with zero coordination — which is the point, since the agents are deliberately
|
||||||
isolated from each other in separate worktrees and cannot coordinate.
|
isolated from each other in separate worktrees (required by `CLAUDE.md` §0) and cannot
|
||||||
|
coordinate.
|
||||||
|
|
||||||
There is deliberately **no index file** and no counter. An index would be a shared
|
There is deliberately **no index file** and no counter. An index would be a shared
|
||||||
mutable file — exactly the thing that just failed — and a sequence number would have
|
mutable file — exactly the thing that just failed — and a sequence number would have
|
||||||
|
|||||||
@@ -306,19 +306,27 @@ node_baseline_dns_servers:
|
|||||||
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
|
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
|
||||||
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
|
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
|
||||||
#
|
#
|
||||||
# THIS USED TO BE BROKEN, AND THE 2026-09-04 SUBDOMAIN SPLIT FIXED IT. While prod was
|
# THE PUBLIC ANSWER IS NOW THE WAN, SO THE ISSUER IS PINNED (2026-10-03). Since
|
||||||
# authored to serve dev's hostnames, a prod node resolving keycloak.home.keithjackson.dev
|
# platform/gateway/gateway.yaml set an external-dns `target`, every public name is a CNAME
|
||||||
# publicly got DEV's Gateway VIP (192.168.1.53) — the wrong cluster, on the wrong LAN, so
|
# to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from the
|
||||||
# kubectl OIDC SSO on prod could not work at all. Now prod's issuer is
|
# LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
|
||||||
# keycloak.prod.home.keithjackson.dev, and the public record for that name is published by
|
# k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18). The apiserver would then
|
||||||
# PROD's own external-dns pointing at PROD's Gateway VIP (192.168.2.20). The public answer
|
# be unable to fetch its issuer's discovery/JWKS, so kubectl OIDC SSO would fail on the
|
||||||
# and the split-horizon answer therefore AGREE, and no /etc/hosts pin is needed.
|
# next k3s restart or Keycloak key rotation. node_baseline_hosts_pins (below) pins the
|
||||||
|
# issuer host to the Gateway VIP in /etc/hosts, which glibc consults before these
|
||||||
|
# resolvers. (History: before the 2026-09-04 subdomain split the public answer was DEV's
|
||||||
|
# VIP; after it, the public answer was prod's own VIP and no pin was needed.)
|
||||||
#
|
#
|
||||||
# STILL TO VERIFY on prod bring-up (PROD-PROVISIONING.md §10): this holds only once prod's
|
# The cert-based admin kubeconfig never depends on any of this. It is the break-glass path.
|
||||||
# external-dns has actually published the record. Until then the issuer does not resolve
|
|
||||||
# from the nodes and kubectl SSO fails — the cert-based admin kubeconfig is the break-glass
|
|
||||||
# path, and it always works. Ordering, not a design gap.
|
|
||||||
node_baseline_dns_search: []
|
node_baseline_dns_search: []
|
||||||
|
# /etc/hosts pins: node-process names that must reach the in-cluster Gateway directly rather
|
||||||
|
# than via the public (WAN, non-hairpinned) answer. See "THE PUBLIC ANSWER IS NOW THE WAN" above.
|
||||||
|
# [parity] the IP MUST equal GATEWAY_VIP in clusters/prod/kustomization.yaml (192.168.2.20),
|
||||||
|
# and the name MUST follow k3s_oidc_issuer_url's host (roles/k3s_server/defaults/main.yml).
|
||||||
|
node_baseline_hosts_pins:
|
||||||
|
- ip: "192.168.2.20"
|
||||||
|
names:
|
||||||
|
- "keycloak.{{ k3s_base_domain }}"
|
||||||
|
|
||||||
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
|
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
|
||||||
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
|
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
|
||||||
|
|||||||
@@ -39,23 +39,46 @@ bpf:
|
|||||||
masquerade: true
|
masquerade: true
|
||||||
|
|
||||||
# Flow observability / isolation audit (ADR-0002).
|
# Flow observability / isolation audit (ADR-0002).
|
||||||
|
# Roll the agents when cilium-config changes. Kept EQUAL to the Flux values
|
||||||
|
# (platform/cilium/helmrelease.yaml), which carry the full rationale: the chart default of false
|
||||||
|
# leaves the pod template untouched on a config change, and cilium-agent reads that ConfigMap only
|
||||||
|
# at startup, so the change is silently inert until someone restarts the DaemonSet by hand.
|
||||||
|
rollOutCiliumPods: true
|
||||||
|
|
||||||
hubble:
|
hubble:
|
||||||
enabled: true
|
enabled: true
|
||||||
relay:
|
relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
# Static flow export -> cilium-agent stdout. Kept EQUAL to the Flux values
|
||||||
|
# (platform/cilium/helmrelease.yaml), which carry the full rationale: it records the PRE-SNAT
|
||||||
|
# client address for Plex's raw tcp/32400, which Plex itself cannot log because every
|
||||||
|
# LoadBalancer Service here is externalTrafficPolicy: Cluster and the VIP lease holder SNATs the
|
||||||
|
# client. Present here so a BOOTSTRAP render does not differ from what Flux reconciles -- a
|
||||||
|
# mismatch would show up as a permanent Helm diff and an avoidable agent rollout. Scoped by PORT,
|
||||||
|
# not namespace: the flow carrying the real client address does not match a namespace filter.
|
||||||
|
export:
|
||||||
|
static:
|
||||||
|
enabled: true
|
||||||
|
filePath: stdout
|
||||||
|
allowList:
|
||||||
|
- '{"destination_port":["32400"]}'
|
||||||
|
- '{"source_port":["32400"]}'
|
||||||
|
|
||||||
# Ingress VIP announcement on the NAT LAN. IP pool / CiliumL2AnnouncementPolicy
|
# Ingress VIP announcement on the NAT LAN. IP pool / CiliumL2AnnouncementPolicy
|
||||||
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
|
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
|
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
|
||||||
|
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
|
||||||
|
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
|
||||||
|
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
|
|
||||||
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
||||||
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
||||||
|
|||||||
@@ -74,12 +74,23 @@ node_baseline_backports_kernel_packages:
|
|||||||
# kernel, and how do I undo it?" from the machine itself.
|
# kernel, and how do I undo it?" from the machine itself.
|
||||||
node_baseline_backports_kernel_stamp: /etc/node-baseline-backports-kernel
|
node_baseline_backports_kernel_stamp: /etc/node-baseline-backports-kernel
|
||||||
|
|
||||||
|
# Modules written to /etc/modules-load.d/k3s-cilium-longhorn.conf (persistent) AND modprobe'd now.
|
||||||
node_baseline_kernel_modules:
|
node_baseline_kernel_modules:
|
||||||
- overlay
|
- overlay
|
||||||
- br_netfilter
|
- br_netfilter
|
||||||
- iscsi_tcp
|
- iscsi_tcp
|
||||||
- dm_crypt
|
- dm_crypt
|
||||||
- nfs
|
- nfs
|
||||||
|
# Remote-access VPN (platform/vpn-ingress). The WireGuard DaemonSet creates its wg0 link in the
|
||||||
|
# HOST netns with CAP_NET_ADMIN only -- it is deliberately NOT granted CAP_SYS_MODULE, so it
|
||||||
|
# cannot autoload this itself: `ip link add wg0 type wireguard` fails outright when the module is
|
||||||
|
# absent, and the pod CrashLoops. Loading it here is therefore a PREREQUISITE of that component,
|
||||||
|
# not a nicety. In-tree since kernel 5.6, so Debian 13's 7.1.8 ships it and there is nothing to
|
||||||
|
# install. Naming it here also means the kernel does the crypto: without the module a WireGuard
|
||||||
|
# container's only fallback is slow userspace wireguard-go, which we do not use (and which would
|
||||||
|
# need /dev/net/tun -- a device a hostPath mount cannot actually grant, as the /dev/dri attempt
|
||||||
|
# proved: visible but EPERM from the device cgroup).
|
||||||
|
- wireguard
|
||||||
|
|
||||||
# --- DATA DISKS (tasks/disks.yml, tag `disks`) ----------------------------------------
|
# --- DATA DISKS (tasks/disks.yml, tag `disks`) ----------------------------------------
|
||||||
# The SHAPE of a node's storage is DECLARED per environment, never inferred from whether
|
# The SHAPE of a node's storage is DECLARED per environment, never inferred from whether
|
||||||
@@ -242,3 +253,9 @@ node_baseline_dns_search:
|
|||||||
node_baseline_dns_options:
|
node_baseline_dns_options:
|
||||||
- timeout:2
|
- timeout:2
|
||||||
- attempts:2
|
- attempts:2
|
||||||
|
# Static /etc/hosts entries (tags: dns, hosts), as a list of {ip, names}. EMPTY by default:
|
||||||
|
# dev needs none. Prod pins its OIDC issuer host to the Gateway VIP because the public
|
||||||
|
# answer for it is the non-hairpinned WAN IP (inventory/group_vars/k3s_prod/vars.yml).
|
||||||
|
# Written as ONE marked block, so removing an entry here removes it from the node, and
|
||||||
|
# an empty list removes the block entirely.
|
||||||
|
node_baseline_hosts_pins: []
|
||||||
@@ -1090,3 +1090,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
when: node_baseline_dns_mechanism == 'resolv-conf-file'
|
when: node_baseline_dns_mechanism == 'resolv-conf-file'
|
||||||
tags: [dns]
|
tags: [dns]
|
||||||
|
|
||||||
|
# /etc/hosts pins — tag `dns` (and `hosts` on its own). Some node processes must reach a
|
||||||
|
# public hostname at the in-cluster Gateway, not at its public answer. On prod, the
|
||||||
|
# kube-apiserver's OIDC issuer resolves publicly to the WAN IP, which the router does not
|
||||||
|
# hairpin. glibc reads /etc/hosts before any nameserver (nsswitch `files dns`), so a pin wins
|
||||||
|
# regardless of which resolver mechanism above is in effect. One marked block: entries
|
||||||
|
# dropped from node_baseline_hosts_pins disappear from the node, and an empty list
|
||||||
|
# removes the block.
|
||||||
|
- name: Pin node-process hostnames in /etc/hosts
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
marker: "# {mark} node_baseline hosts pins (Ansible, tags: hosts)"
|
||||||
|
block: |
|
||||||
|
{% for pin in node_baseline_hosts_pins %}
|
||||||
|
{{ pin.ip }} {{ pin.names | join(' ') }}
|
||||||
|
{% endfor %}
|
||||||
|
state: "{{ 'present' if node_baseline_hosts_pins | length > 0 else 'absent' }}"
|
||||||
|
backup: true
|
||||||
|
tags: [dns, hosts]
|
||||||
+63
-1
@@ -52,7 +52,7 @@ All four steps are complete and each was verified, not assumed:
|
|||||||
| 1. Runner registered | ✅ `incluster-dind` online. Root cause of its absence was a stale `/data/.runner` identity; clearing the PVC forced re-registration |
|
| 1. Runner registered | ✅ `incluster-dind` online. Root cause of its absence was a stale `/data/.runner` identity; clearing the PVC forced re-registration |
|
||||||
| 2. Gate observed green on a real PR | ✅ `gate / gate (pull_request)` ~1m35s |
|
| 2. Gate observed green on a real PR | ✅ `gate / gate (pull_request)` ~1m35s |
|
||||||
| 3. `enable_status_check: true` | ✅ context `gate / gate (pull_request)` — that one ONLY |
|
| 3. `enable_status_check: true` | ✅ context `gate / gate (pull_request)` — that one ONLY |
|
||||||
| 4. Collaborator + approvals | ✅ `jakman85` is owner; `required_approvals: 1` |
|
| 4. Collaborator + approvals | ✅ `jakman85` is owner. `required_approvals` was raised to 1 here and **lowered back to 0 on 2026-10-01** — see §6, it was a lock-out |
|
||||||
|
|
||||||
Direct push to `main` was **provoked and rejected** (`pre-receive hook declined`) as
|
Direct push to `main` was **provoked and rejected** (`pre-receive hook declined`) as
|
||||||
`gitea-admin`, a site admin — so push protection is not merely declared, it holds for the
|
`gitea-admin`, a site admin — so push protection is not merely declared, it holds for the
|
||||||
@@ -124,6 +124,66 @@ the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS
|
|||||||
**Changing the declaration is how you change policy.** If the live state is what you now want,
|
**Changing the declaration is how you change policy.** If the live state is what you now want,
|
||||||
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
|
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
|
||||||
|
|
||||||
|
### 6. `allow-admin-self-merge.sh` — undoing the lock-out step 4 created (2026-10-01)
|
||||||
|
|
||||||
|
Step 4 above raised `required_approvals` to 1. That was a **lock-out, not a protection**, and the
|
||||||
|
note at the bottom of `require-gate.sh` had already said why: *Gitea will not let an author approve
|
||||||
|
their own PR.* With `jakman85` the only collaborator, there was no account in existence that could
|
||||||
|
approve a PR `jakman85` opened.
|
||||||
|
|
||||||
|
Measured 2026-10-01 — **PR #35**: `gate / gate (pull_request)` **success in 1m41s**, reviews `[]`,
|
||||||
|
`required_approvals: 1`, `block_admin_merge_override: true`. Unmergeable, permanently, with no
|
||||||
|
action available to its author. The same was true of any PR fixing it.
|
||||||
|
|
||||||
|
It was also never the documented design. `ci/approval-policy.sh`'s header and `ci/gate.sh:194`
|
||||||
|
both already describe the model as *"`required_approvals` drops to 0 and the GATE decides"*, and
|
||||||
|
`decisions/2026-09-30-flux-deploy-notification-inhibited.md` flagged the 1-vs-0 disagreement as
|
||||||
|
unresolved and not that session's to settle. So this is a correction toward the stated intent, not
|
||||||
|
a weakening away from it.
|
||||||
|
|
||||||
|
./infra/gitea/allow-admin-self-merge.sh # required_approvals -> 0 (read from the declaration)
|
||||||
|
|
||||||
|
**What stays switched on, and why that is the point.** The other way to unblock an owner is
|
||||||
|
`block_admin_merge_override: false`, and it would have been the wrong one — that re-opens exactly
|
||||||
|
what §5 above closed, letting the one account that merges bypass the gate itself. This script
|
||||||
|
touches the approval **count** only and *verifies* afterwards that `block_admin_merge_override` and
|
||||||
|
`enable_status_check` are both still `true`, failing loudly if the PATCH disturbed either:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `required_approvals` | 1 | **0** |
|
||||||
|
| `block_admin_merge_override` | true | true (asserted) |
|
||||||
|
| `enable_status_check` | true | true (asserted) |
|
||||||
|
|
||||||
|
So a **red gate is still unmergeable by everyone, administrators included**. What changed is that
|
||||||
|
a *green* one no longer needs a signature that cannot exist.
|
||||||
|
|
||||||
|
**Security-relevant changes are still reviewed** — by `ci/approval-policy.sh` against
|
||||||
|
`approval-policy.json` `sensitive_paths`, not by this field. An administrator waives that review
|
||||||
|
only by posting `/approve-override <reason>` on the PR: reason mandatory, permission re-checked
|
||||||
|
live, invalidated by a new commit, and recorded by account in the gate summary.
|
||||||
|
|
||||||
|
> **Post the override as a PR *review* (type "Comment"), not in the plain comment box** — until
|
||||||
|
> the CI token is re-minted. `SETTINGS_API_TOKEN` is `[read:repository, read:admin]`, and
|
||||||
|
> `/issues/{n}/comments` needs Gitea's *issue* scope, so Gitea answers with an error **object**
|
||||||
|
> instead of an array and the override is invisible. Measured on PR #38 (2026-10-02): a correct,
|
||||||
|
> fresh, well-formed override in the comment box produced `approval-policy: FAIL` reading as "no
|
||||||
|
> approving review" — a permission failure disguised as a missing one. `/pulls/{n}/reviews` is
|
||||||
|
> under `read:repository` and worked in the same run. The stage now reads **both** sources, names
|
||||||
|
> which one it used, and says plainly when the comments API is unreadable. `set-ci-token.sh`
|
||||||
|
> requests `read:issue` as of 2026-10-02, so **re-run it** and the comment box works too — token
|
||||||
|
> scopes cannot be widened in place. Note that the
|
||||||
|
**agent account `gitea-admin` also reports permission `owner`**, so it can clear its own PRs that
|
||||||
|
way; that is an accepted consequence of authorising by permission alone, and the route to
|
||||||
|
narrowing it (an `override_admins` allowlist, already honoured by the code) is written down in
|
||||||
|
`approval-policy.json`.
|
||||||
|
|
||||||
|
**Sequencing.** `ci/gitea-settings.sh` compares live against the declaration *from the branch under
|
||||||
|
test*, and `REQUIRE_GITEA_SETTINGS=true`. So merge any other open PRs **before** running this, then
|
||||||
|
run it, then re-run the gate on the branch carrying the new declaration. Any PR left open across
|
||||||
|
the change declares 1 against a live 0 and goes **red until rebased** — the detector working, not
|
||||||
|
a fault. The script's header spells the order out.
|
||||||
|
|
||||||
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
|
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
|
||||||
|
|
||||||
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
|
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
|
||||||
@@ -204,6 +264,8 @@ it. The name matters — see the note under the behaviour table above.)
|
|||||||
2. **Watch the gate actually report on a real PR**, and note the context name it produced.
|
2. **Watch the gate actually report on a real PR**, and note the context name it produced.
|
||||||
3. **Only then set `enable_status_check: true`** with that exact context.
|
3. **Only then set `enable_status_check: true`** with that exact context.
|
||||||
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
|
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
|
||||||
|
— **Step 4 was REVERSED on 2026-10-01 (§6).** It assumed a reviewer distinct from the author
|
||||||
|
would exist. One never did, so this step produced a lock-out. Do not re-apply it.
|
||||||
|
|
||||||
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
|
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
|
||||||
> PR permanently pending — including the PR that would fix it. That half is measured: PR #1
|
> PR permanently pending — including the PR that would fix it. That half is measured: PR #1
|
||||||
|
|||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Let the sole administrator merge their own green PR: required_approvals 1 -> 0 on `main`.
|
||||||
|
#
|
||||||
|
# WHY. `required_approvals: 1` plus Gitea's refusal to let an author approve their own pull
|
||||||
|
# request plus exactly ONE collaborator (`jakman85`, permission `owner`) is a lock-out, not a
|
||||||
|
# policy. Every PR the operator opened was permanently unmergeable, including the PR that would
|
||||||
|
# fix it. Measured 2026-10-01: PR #35 — `gate / gate (pull_request)` success in 1m41s, zero
|
||||||
|
# reviews, `required_approvals: 1`, no account in existence able to satisfy it.
|
||||||
|
#
|
||||||
|
# The approvals field was also never what the design said it was. `ci/approval-policy.sh`'s
|
||||||
|
# header and `ci/gate.sh:194` both already described the model as "required_approvals: 0 and the
|
||||||
|
# GATE decides", and `require-gate.sh`'s own closing note named this exact failure and this exact
|
||||||
|
# remedy. decisions/2026-09-30-flux-deploy-notification-inhibited.md flagged the 1-vs-0
|
||||||
|
# disagreement as unresolved. This resolves it at 0.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* DO, and the distinction is the whole point.
|
||||||
|
#
|
||||||
|
# * It does NOT touch `block_admin_merge_override`, which stays TRUE. The required status check
|
||||||
|
# still binds ABSOLUTELY — a red gate is unmergeable by everyone, administrators included.
|
||||||
|
# Setting `block_admin_merge_override: false` would have been the other way to unblock an
|
||||||
|
# owner, and it would have re-opened precisely what block-admin-override.sh was written to
|
||||||
|
# close: PRs #5-#9 merged with zero approvals while the settings read as enforced.
|
||||||
|
# * It does NOT stop reviewing security-relevant changes. Those are gated by
|
||||||
|
# ci/approval-policy.sh against infra/gitea/approval-policy.json `sensitive_paths`, which an
|
||||||
|
# administrator can waive only with an explicit, attributed `/approve-override <reason>`
|
||||||
|
# comment on the PR.
|
||||||
|
#
|
||||||
|
# ORDER MATTERS — READ THIS BEFORE RUNNING IT.
|
||||||
|
#
|
||||||
|
# ci/gitea-settings.sh fails the merge gate on ANY divergence between the live rule and
|
||||||
|
# infra/gitea/expected-branch-protection.json, and `gate.yml` sets REQUIRE_GITEA_SETTINGS=true.
|
||||||
|
# The declaration in git now says 0. So between running this and merging that declaration, the
|
||||||
|
# live value and the declared value must be compared from a branch that CARRIES the new
|
||||||
|
# declaration — otherwise the stage goes red, correctly, for drift.
|
||||||
|
#
|
||||||
|
# 1. MERGE any other open PRs FIRST, while approvals still work for them (their branches
|
||||||
|
# declare 1, and a PR you did not author can still be approved normally). Skipping this
|
||||||
|
# only costs a rebase later; it does not break anything.
|
||||||
|
# 2. Run this script.
|
||||||
|
# 3. Re-run the gate on the branch that carries the new declaration. `gitea-settings` goes
|
||||||
|
# green because live 0 == declared 0.
|
||||||
|
# 4. Merge that branch.
|
||||||
|
# 5. Any PR still open from before step 2 now declares 1 against a live 0 and will be RED
|
||||||
|
# until it is rebased onto the new main. That is the detector doing its job, not a fault.
|
||||||
|
#
|
||||||
|
# Recoverable: `required_approvals` can be PATCHed back to 1 by an owner at any time. If you do,
|
||||||
|
# change expected-branch-protection.json in the same breath or every PR goes red on drift.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
DECL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/expected-branch-protection.json"
|
||||||
|
|
||||||
|
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
|
||||||
|
[ -f "$DECL" ] || { echo "declaration missing: ${DECL}" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Take the target from the DECLARATION, not from a literal here. If the two could disagree,
|
||||||
|
# this script would be one more thing re-asserting a value nothing checks — which is the
|
||||||
|
# failure mode the whole of infra/gitea/ exists to stop.
|
||||||
|
WANT="$(jq -r '.protection.required_approvals' "$DECL")"
|
||||||
|
[[ "$WANT" =~ ^[0-9]+$ ]] || { echo "declaration has no numeric required_approvals" >&2; exit 1; }
|
||||||
|
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
[ -n "$U" ] && [ -n "$P" ] || { echo "no stored credential for $HOST" >&2; exit 1; }
|
||||||
|
echo "authenticating as '${U}' (secret not shown)"
|
||||||
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
B="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main"
|
||||||
|
|
||||||
|
# Read the RULE OBJECT, never /branches/main: the branch view does not expose
|
||||||
|
# block_admin_merge_override and showed all-green for hours while the rule was overridable.
|
||||||
|
echo "==> before"
|
||||||
|
api "$B" | jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}'
|
||||||
|
|
||||||
|
echo "==> setting required_approvals = ${WANT} (declared in expected-branch-protection.json)"
|
||||||
|
api -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"required_approvals\": ${WANT}}" "$B" >/dev/null
|
||||||
|
|
||||||
|
echo "==> after"
|
||||||
|
AFTER_JSON="$(api "$B")"
|
||||||
|
jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}' <<<"$AFTER_JSON"
|
||||||
|
|
||||||
|
GOT="$(jq -r '.required_approvals' <<<"$AFTER_JSON")"
|
||||||
|
ADMIN_BLOCK="$(jq -r '.block_admin_merge_override' <<<"$AFTER_JSON")"
|
||||||
|
STATUS="$(jq -r '.enable_status_check' <<<"$AFTER_JSON")"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
[ "$GOT" = "$WANT" ] || { echo "PATCH did not take: required_approvals is ${GOT}, wanted ${WANT}" >&2; fail=1; }
|
||||||
|
# These two are what keeps this change narrow. If the PATCH disturbed either, the gate has
|
||||||
|
# stopped binding and that must be loud, not discovered later.
|
||||||
|
[ "$ADMIN_BLOCK" = true ] || { echo "block_admin_merge_override is ${ADMIN_BLOCK}, MUST be true — the gate is now bypassable" >&2; fail=1; }
|
||||||
|
[ "$STATUS" = true ] || { echo "enable_status_check is ${STATUS}, MUST be true — the gate is no longer required" >&2; fail=1; }
|
||||||
|
[ "$fail" -eq 0 ] || { echo "do NOT record this as applied." >&2; exit 1; }
|
||||||
|
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
NOW PROVE IT -- the setting is not the evidence. Two things to see, not assume:
|
||||||
|
|
||||||
|
1. An APPROVAL IS NO LONGER NEEDED. On your own PR with a green gate, the merge button is
|
||||||
|
live with zero reviews on it.
|
||||||
|
2. THE GATE STILL BINDS. On a PR whose gate is RED or still pending, Gitea must REFUSE the
|
||||||
|
merge -- for you, as owner. If it lets you through, block_admin_merge_override is not
|
||||||
|
doing its job and this change has traded a lock-out for a bypass.
|
||||||
|
|
||||||
|
Then confirm the detector agrees: run ci/gate.sh from a branch carrying the new
|
||||||
|
expected-branch-protection.json and check that `gitea-settings` is green rather than DRIFT.
|
||||||
|
NOTE
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
||||||
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
|
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author, OR an admin override (see .override). Everything else merges on a green gate alone.",
|
||||||
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
||||||
"sensitive_paths": [
|
"sensitive_paths": [
|
||||||
"ci/**",
|
"ci/**",
|
||||||
@@ -24,5 +24,18 @@
|
|||||||
"platform/kyverno/**": "the admission policy engine",
|
"platform/kyverno/**": "the admission policy engine",
|
||||||
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
||||||
"CLAUDE.md": "the delivery contract agents follow"
|
"CLAUDE.md": "the delivery contract agents follow"
|
||||||
|
},
|
||||||
|
"override": {
|
||||||
|
"_why": "This repository has ONE human. Gitea refuses to let an author approve their own pull request, so 'a non-author APPROVED review' is a requirement the sole administrator can never satisfy -- every sensitive-path PR they open was permanently unmergeable, including the PR that would fix it. Measured 2026-10-01: PR #35 green gate, zero reviews, unmergeable. An escape hatch that the administrator can actually reach is therefore REQUIRED for the repository to function; the design question is only whether it leaves a record. This one does.",
|
||||||
|
"enabled": true,
|
||||||
|
"comment_directive": "/approve-override",
|
||||||
|
"requires_reason": true,
|
||||||
|
"authorized_permissions": ["admin", "owner"],
|
||||||
|
"invalidated_by_new_commits": true,
|
||||||
|
"_how": "Post the directive at the START of a line, followed by a reason: '/approve-override <why>'. ci/approval-policy.sh resolves the poster's LIVE repo permission via /collaborators/{login}/permission and accepts the override only if it is in authorized_permissions. The overrider, the source, the timestamp and the reason are printed in the gate log and the job summary.",
|
||||||
|
"_where_to_post_it": "EITHER a PR REVIEW body (type 'Comment') OR the ordinary PR comment box. Prefer the REVIEW: measured 2026-10-02 on PR #38, a well-formed override in the comment box was INVISIBLE to CI, because /issues/{n}/comments sits behind Gitea's *issue* token scope and SETTINGS_API_TOKEN is minted [read:repository, read:admin]. Gitea returned an error object rather than an array, and the stage reported 'no approving review' -- a permission problem wearing the costume of a missing override. /pulls/{n}/reviews is under read:repository and was readable in the same run, so a review body is the route that works on today's token. infra/gitea/set-ci-token.sh now also requests read:issue, which makes the comment box work -- but only after that script is RE-RUN, since an existing token's scopes cannot be widened in place. The stage accepts both sources and names which one it used, so neither route is load-bearing alone.",
|
||||||
|
"_self_override_is_allowed": "The author may override their own PR if they hold an authorized permission. That is the entire point -- with one human there is no one else -- but it means the 'a change to the gate cannot be gated by the gate' protection is, for an administrator, advisory rather than structural. It is traded for an auditable record: the waiver is an explicit act, attributed, with a stated reason, in the PR's comment history and the gate output.",
|
||||||
|
"_known_consequence_agent_accounts": "ACCEPTED, EYES OPEN (operator decision, 2026-10-01). Authorisation is by live permission ALONE, with no allowlist of human accounts. The agent account `gitea-admin` reports permission `owner` on this repo (measured 2026-10-01), so an AGENT can clear the sensitive-path review on its own PR by posting the directive. Agents author most PRs here, so this is the broad case, not the edge case. To narrow it later without changing any logic, add an `override_admins` allowlist here and require membership in addition to the permission check -- ci/approval-policy.sh reads both from this file.",
|
||||||
|
"_staleness": "An override is dropped unless its comment is NEWER than the PR head commit, mirroring Gitea's own dismiss_stale_approvals. An override earned at commit A therefore does not silently cover commit B; push again and you must override again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"_comment": "DECLARED desired state of Gitea branch protection on main. ci/gitea-settings.sh fails the merge gate when the LIVE Gitea diverges from this. Gitea settings live in Gitea database - invisible to Flux, unreadable by every other check - so this file plus that check are the only drift detection that exists for them.",
|
"_comment": "DECLARED desired state of Gitea branch protection on main. ci/gitea-settings.sh fails the merge gate when the LIVE Gitea diverges from this. Gitea settings live in Gitea database - invisible to Flux, unreadable by every other check - so this file plus that check are the only drift detection that exists for them.",
|
||||||
"_generated": "2026-09-29 from the live rule, after the session that found protection OFF and zero runners for a month",
|
"_generated": "2026-09-29 from the live rule, after the session that found protection OFF and zero runners for a month",
|
||||||
|
"_amended": "2026-10-01: required_approvals 1 -> 0. See _why_required_approvals_is_zero.",
|
||||||
|
"_why_required_approvals_is_zero": "The GATE is the approver (operator intent, 2026-09-29). required_approvals: 1 was never consistent with that intent: ci/approval-policy.sh's header and ci/gate.sh:194 both already described the model as 'required_approvals: 0 and the GATE decides', and infra/gitea/require-gate.sh's own closing note named the consequence of 1 -- 'Gitea will not let an author approve their own PR'. With exactly ONE collaborator (jakman85, owner), that made every PR the operator opened permanently unmergeable. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, no path to a merge. decisions/2026-09-30-flux-deploy-notification-inhibited.md had already flagged the 1-vs-0 disagreement as unresolved; this settles it at 0.",
|
||||||
|
"_what_still_holds_at_zero": "This drops the APPROVAL COUNT only. block_admin_merge_override stays TRUE and enable_status_check stays TRUE, so the required gate context still binds absolutely -- nobody, administrator included, can merge a red gate. That distinction is the whole reason this is the chosen fix rather than block_admin_merge_override: false, which would have handed admins a bypass over the gate itself and re-opened what infra/gitea/block-admin-override.sh was written to close (PRs #5-#9, merged with zero approvals). Architecturally and security-relevant paths are still reviewed, by ci/approval-policy.sh rather than by this field.",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"runners_online_min": 1,
|
"runners_online_min": 1,
|
||||||
"protection": {
|
"protection": {
|
||||||
@@ -11,7 +14,7 @@
|
|||||||
"status_check_contexts": [
|
"status_check_contexts": [
|
||||||
"gate / gate (pull_request)"
|
"gate / gate (pull_request)"
|
||||||
],
|
],
|
||||||
"required_approvals": 1,
|
"required_approvals": 0,
|
||||||
"enable_approvals_whitelist": false,
|
"enable_approvals_whitelist": false,
|
||||||
"block_admin_merge_override": true,
|
"block_admin_merge_override": true,
|
||||||
"block_on_rejected_reviews": true,
|
"block_on_rejected_reviews": true,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jq -n '{
|
|||||||
enable_push: false,
|
enable_push: false,
|
||||||
enable_status_check: true,
|
enable_status_check: true,
|
||||||
status_check_contexts: ["gate / gate (pull_request)"],
|
status_check_contexts: ["gate / gate (pull_request)"],
|
||||||
required_approvals: 1,
|
required_approvals: 0,
|
||||||
block_on_rejected_reviews: true,
|
block_on_rejected_reviews: true,
|
||||||
dismiss_stale_approvals: true,
|
dismiss_stale_approvals: true,
|
||||||
block_on_outdated_branch: false,
|
block_on_outdated_branch: false,
|
||||||
@@ -48,11 +48,21 @@ api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
|
|||||||
|
|
||||||
cat <<'NOTE'
|
cat <<'NOTE'
|
||||||
|
|
||||||
NOTE ON required_approvals: 1 -- Gitea will not let an author approve their own PR.
|
NOTE ON required_approvals: 0 -- CHANGED FROM 1 ON 2026-10-01, and this script is the reason
|
||||||
* Agent-opened PRs (author gitea-admin) are approved by jakman85. Fine.
|
the change had to land here too: re-running it with 1 would silently re-impose the lock-out.
|
||||||
* PRs YOU open as jakman85 need gitea-admin to approve. If that is awkward, either open
|
|
||||||
agent work as gitea-admin and review as jakman85, or set required_approvals back to 0
|
The note that used to sit here called the problem and its own fix: "Gitea will not let an
|
||||||
and rely on the status check alone.
|
author approve their own PR ... if that is awkward ... set required_approvals back to 0 and
|
||||||
|
rely on the status check alone." It was awkward. With jakman85 the ONLY collaborator, every PR
|
||||||
|
the operator opened was unmergeable -- measured on PR #35, gate green in 1m41s, zero reviews,
|
||||||
|
nothing that could ever satisfy the rule. So: 0, and the status check alone, as written.
|
||||||
|
|
||||||
|
What did NOT change, deliberately:
|
||||||
|
* block_admin_merge_override stays TRUE. This drops the approval COUNT; it does not hand
|
||||||
|
anyone a bypass over the gate. A red gate is still unmergeable by everybody.
|
||||||
|
* Architecturally/security-relevant paths are still human-reviewed -- by ci/approval-policy.sh
|
||||||
|
(sensitive_paths in infra/gitea/approval-policy.json), which an administrator can waive
|
||||||
|
only by posting an explicit, attributed `/approve-override <reason>` comment.
|
||||||
|
|
||||||
Then prove it, rather than trusting the API:
|
Then prove it, rather than trusting the API:
|
||||||
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
|
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
|
||||||
|
|||||||
@@ -5,9 +5,23 @@
|
|||||||
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
||||||
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
||||||
#
|
#
|
||||||
# Scope: `read:repository` + `read:admin` are what the check needs (the rule object, and the
|
# Scope: `read:repository` + `read:admin` are what ci/gitea-settings.sh needs (the rule object,
|
||||||
# runner list). Gitea token scopes are coarse; grant the least that works on your version and
|
# and the runner list). `read:issue` is for ci/approval-policy.sh, and it was MISSING — see below.
|
||||||
# confirm the check goes green rather than assuming the scope name.
|
# Gitea token scopes are coarse; grant the least that works on your version and confirm the check
|
||||||
|
# goes green rather than assuming the scope name.
|
||||||
|
#
|
||||||
|
# WHY read:issue IS HERE (added 2026-10-02, measured on PR #38). ci/approval-policy.sh reads the
|
||||||
|
# PR's comments to find an admin `/approve-override`. /issues/{n}/comments sits behind the *issue*
|
||||||
|
# scope, which this script did not request — so Gitea answered with an error OBJECT instead of an
|
||||||
|
# array, and a perfectly well-formed override posted in the PR comment box was invisible to CI.
|
||||||
|
# The failure did not look like a permission problem from the outside; it looked like the override
|
||||||
|
# had not been posted. `/pulls/{n}/files` and `/pulls/{n}/reviews` are under read:repository, which
|
||||||
|
# is why every other part of the same stage worked and this one did not.
|
||||||
|
#
|
||||||
|
# The stage now accepts an override from a REVIEW body as well, so it still functions on a token
|
||||||
|
# WITHOUT read:issue. This scope is what makes the ordinary comment box work too — the thing
|
||||||
|
# anybody would reach for first. **Re-run this script to pick it up; an existing token's scopes
|
||||||
|
# cannot be widened in place.**
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
|
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
|
||||||
@@ -21,7 +35,7 @@ basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
|||||||
|
|
||||||
echo "==> minting token '${NAME}' as ${U}"
|
echo "==> minting token '${NAME}' as ${U}"
|
||||||
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
||||||
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\"]}" \
|
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\",\"read:issue\"]}" \
|
||||||
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
||||||
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
||||||
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
||||||
|
|||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =================================================================================================
|
||||||
|
# new-peer.sh — enrol one device on the remote-access VPN (platform/vpn-ingress).
|
||||||
|
#
|
||||||
|
# Generates a WireGuard keypair ON THIS MACHINE, renders the device's client profile, optionally
|
||||||
|
# prints a QR code for a phone, and prints the one `[Peer]` stanza to paste into
|
||||||
|
# platform/vpn-ingress/peers.yaml. The private half NEVER leaves this machine: it is not sent to the
|
||||||
|
# cluster, not committed, and not written to any log.
|
||||||
|
#
|
||||||
|
# WHY A SCRIPT AND NOT A RUNBOOK STEP. Three of these fields are easy to get wrong in ways that are
|
||||||
|
# either silently insecure or silently broken, and all three are mechanical:
|
||||||
|
# * AllowedIPs on the SERVER side must be a /32. On the server, AllowedIPs is simultaneously the
|
||||||
|
# cryptokey routing table AND the inbound source filter, so a /24 here lets one device spoof
|
||||||
|
# every other device's tunnel address.
|
||||||
|
# * PersistentKeepalive must be set on the CLIENT. Without it the NAT mapping at the client's end
|
||||||
|
# expires after ~30s of idle and nothing can reach the device until it speaks first — which
|
||||||
|
# presents as "the VPN works for a minute and then half-works".
|
||||||
|
# * The endpoint must be the DDNS name, never the current WAN IP (see --endpoint below).
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# ./infra/vpn-ingress/new-peer.sh <device-name> <last-octet> [--split] [--out DIR]
|
||||||
|
#
|
||||||
|
# <device-name> short, lowercase, e.g. pixel-8, thinkpad, work-laptop. Used for the filename
|
||||||
|
# and the comment that ties the key to a human-recognisable device.
|
||||||
|
# <last-octet> 2-254, the device's address inside WG_CLIENT_CIDR. Must be unused.
|
||||||
|
# --split split tunnel: route ONLY the LAN and the tunnel subnet. Default is a FULL
|
||||||
|
# tunnel (0.0.0.0/0) so the device also egresses the house WAN.
|
||||||
|
# --out DIR where to write the profile (default: a 0700 dir under $TMPDIR).
|
||||||
|
#
|
||||||
|
# ENVIRONMENT
|
||||||
|
# WG_SERVER_KEY_FILE server PRIVATE key (default ~/.config/wireguard-home/server.key); its public
|
||||||
|
# half is derived from it. Alternatively set WG_SERVER_PUBKEY directly.
|
||||||
|
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
|
||||||
|
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
|
||||||
|
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
|
||||||
|
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
|
||||||
|
#
|
||||||
|
# REQUIRES: wireguard-tools (`wg`). `qrencode` is optional and only used for the QR code.
|
||||||
|
# =================================================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { printf 'new-peer.sh: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.yaml"
|
||||||
|
|
||||||
|
# --- defaults, each mirroring a value that is DECLARED elsewhere in this repo -------------------
|
||||||
|
# These are duplicated here rather than parsed out of the YAML on purpose: this script is meant to
|
||||||
|
# run on an operator's laptop with no checkout-shaped assumptions beyond its own location, and a
|
||||||
|
# silent mis-parse would be worse than a visible default. If you change the knob, change it here.
|
||||||
|
: "${WG_CLIENT_CIDR:=10.90.0.0/24}" # clusters/base/cluster-vars.yaml
|
||||||
|
: "${WG_LAN_CIDR:=192.168.2.0/24}" # clusters/prod LAN_CIDR
|
||||||
|
: "${WG_DNS:=192.168.2.23}" # clusters/prod PIHOLE_DNS_VIP
|
||||||
|
: "${WG_SERVER_KEY_FILE:=${HOME}/.config/wireguard-home/server.key}"
|
||||||
|
# wan.<base-domain> is an UNPROXIED A record that platform/externaldns/ddns.yaml keeps pointed at
|
||||||
|
# the current WAN IP (checked every 5 min). Use it, never a literal address: this house has a
|
||||||
|
# DYNAMIC WAN IP, and a profile pinned to today's address stops working without warning and with no
|
||||||
|
# symptom on the server side. It is a PUBLIC Cloudflare record, so it also resolves from a cellular
|
||||||
|
# connection — unlike any `*.home.keithjackson.dev` name PiHole answers, which a remote client
|
||||||
|
# cannot resolve until it is already connected.
|
||||||
|
: "${WG_ENDPOINT_HOST:=wan.home.keithjackson.dev}"
|
||||||
|
: "${WG_LISTEN_PORT:=51820}"
|
||||||
|
: "${WG_MTU:=1420}"
|
||||||
|
|
||||||
|
SPLIT=0
|
||||||
|
OUT_DIR=""
|
||||||
|
NAME=""
|
||||||
|
OCTET=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--split) SPLIT=1; shift ;;
|
||||||
|
--out) OUT_DIR="${2:-}"; [ -n "$OUT_DIR" ] || die "--out needs a directory"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||||
|
-*) die "unknown option: $1" ;;
|
||||||
|
*) if [ -z "$NAME" ]; then NAME="$1"; elif [ -z "$OCTET" ]; then OCTET="$1"; else die "unexpected argument: $1"; fi; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$NAME" ] || die "need a device name. See --help"
|
||||||
|
[ -n "$OCTET" ] || die "need a last octet (2-254). See --help"
|
||||||
|
printf '%s' "$NAME" | grep -Eq '^[a-z0-9][a-z0-9-]{0,30}$' \
|
||||||
|
|| die "device name must be lowercase letters/digits/hyphens: got '$NAME'"
|
||||||
|
printf '%s' "$OCTET" | grep -Eq '^[0-9]+$' || die "octet must be a number: got '$OCTET'"
|
||||||
|
[ "$OCTET" -ge 2 ] && [ "$OCTET" -le 254 ] || die "octet must be 2-254 (.1 is the server): got '$OCTET'"
|
||||||
|
|
||||||
|
command -v wg >/dev/null || die "wireguard-tools not installed ('wg' not on PATH). Debian/Ubuntu: apt install wireguard-tools"
|
||||||
|
|
||||||
|
# --- the server's public key --------------------------------------------------------------------
|
||||||
|
if [ -z "${WG_SERVER_PUBKEY:-}" ]; then
|
||||||
|
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read server key '$WG_SERVER_KEY_FILE'.
|
||||||
|
Either point WG_SERVER_KEY_FILE at it, or set WG_SERVER_PUBKEY to the public half.
|
||||||
|
If the server key does not exist yet, see runbooks/vpn-remote-access.md step 1."
|
||||||
|
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
|
||||||
|
CLIENT_ADDR="${CLIENT_BASE}.${OCTET}"
|
||||||
|
|
||||||
|
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
|
||||||
|
# Two devices sharing an address does not error anywhere: the server routes that address to
|
||||||
|
# whichever peer handshook most recently, so BOTH devices appear to work intermittently.
|
||||||
|
if [ -r "$PEERS_FILE" ] && grep -q "AllowedIPs *= *${CLIENT_ADDR}/32" "$PEERS_FILE"; then
|
||||||
|
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet."
|
||||||
|
fi
|
||||||
|
if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
|
||||||
|
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.yaml.\n' "$NAME" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- output location: 0700, and never inside the repo -------------------------------------------
|
||||||
|
if [ -z "$OUT_DIR" ]; then
|
||||||
|
OUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wg-peer-XXXXXX")"
|
||||||
|
else
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
fi
|
||||||
|
chmod 700 "$OUT_DIR"
|
||||||
|
case "$(cd "$OUT_DIR" && pwd)" in
|
||||||
|
"$REPO_ROOT"|"$REPO_ROOT"/*) die "refusing to write a private key inside the git checkout ($OUT_DIR)" ;;
|
||||||
|
esac
|
||||||
|
PROFILE="${OUT_DIR}/${NAME}.conf"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
PRIVKEY="$(wg genkey)"
|
||||||
|
PUBKEY="$(printf '%s' "$PRIVKEY" | wg pubkey)"
|
||||||
|
|
||||||
|
if [ "$SPLIT" -eq 1 ]; then
|
||||||
|
ALLOWED="${WG_LAN_CIDR}, ${WG_CLIENT_CIDR}"
|
||||||
|
TUNNEL_KIND="split tunnel (LAN only; the device keeps using its own internet)"
|
||||||
|
else
|
||||||
|
ALLOWED="0.0.0.0/0"
|
||||||
|
TUNNEL_KIND="FULL tunnel (all IPv4 traffic egresses the house WAN)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$PROFILE" <<EOF
|
||||||
|
# ${NAME} — remote-access VPN profile for home.keithjackson.dev
|
||||||
|
# Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by infra/vpn-ingress/new-peer.sh
|
||||||
|
# ${TUNNEL_KIND}
|
||||||
|
#
|
||||||
|
# THIS FILE CONTAINS A PRIVATE KEY. Move it to the device, then delete it from here.
|
||||||
|
# It is the ONLY copy: the server never sees it and it cannot be recovered or re-derived.
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = ${PRIVKEY}
|
||||||
|
Address = ${CLIENT_ADDR}/32
|
||||||
|
DNS = ${WG_DNS}
|
||||||
|
MTU = ${WG_MTU}
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = ${WG_SERVER_PUBKEY}
|
||||||
|
Endpoint = ${WG_ENDPOINT_HOST}:${WG_LISTEN_PORT}
|
||||||
|
AllowedIPs = ${ALLOWED}
|
||||||
|
# Hold the NAT mapping open from the client side. Required for the server to be able to reach the
|
||||||
|
# device at all once it goes idle; harmless otherwise (one small packet every 25s).
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
EOF
|
||||||
|
chmod 600 "$PROFILE"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Device : ${NAME}
|
||||||
|
Address : ${CLIENT_ADDR}/32
|
||||||
|
Routing : ${TUNNEL_KIND}
|
||||||
|
DNS : ${WG_DNS} (PiHole — gives the device LAN answers for *.home.keithjackson.dev)
|
||||||
|
Profile : ${PROFILE} (0600; contains the private key)
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------------------
|
||||||
|
STEP 1 — add this to platform/vpn-ingress/peers.yaml, under data.peers.conf, then PR it:
|
||||||
|
|
||||||
|
# ${NAME} — <owner>, enrolled $(date -u +%Y-%m-%d)
|
||||||
|
[Peer]
|
||||||
|
PublicKey = ${PUBKEY}
|
||||||
|
AllowedIPs = ${CLIENT_ADDR}/32
|
||||||
|
|
||||||
|
Keep the comment line: \`wg show\` can only print the key, so it is the only record of which
|
||||||
|
stanza belongs to which device when one has to be revoked.
|
||||||
|
|
||||||
|
STEP 2 — get the profile onto the device:
|
||||||
|
Android / iOS : scan the QR code below in the WireGuard app ("+" -> Scan from QR code)
|
||||||
|
Windows / macOS: WireGuard app -> "Import tunnel(s) from file" -> ${NAME}.conf
|
||||||
|
Linux : install as /etc/wireguard/${NAME}.conf, then
|
||||||
|
sudo systemctl enable --now wg-quick@${NAME}
|
||||||
|
|
||||||
|
STEP 3 — delete the profile from this machine once the device has it:
|
||||||
|
shred -u ${PROFILE} 2>/dev/null || rm -f ${PROFILE}
|
||||||
|
|
||||||
|
The tunnel will not work until the peers.yaml PR is MERGED and Flux has rolled the DaemonSet
|
||||||
|
(~1 min). Until then the device handshakes and gets silence, exactly like an unknown key.
|
||||||
|
------------------------------------------------------------------------------------------
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if command -v qrencode >/dev/null; then
|
||||||
|
printf ' QR code (this IS the private key — your terminal scrollback now holds a credential):\n\n'
|
||||||
|
qrencode -t ansiutf8 < "$PROFILE"
|
||||||
|
else
|
||||||
|
printf ' qrencode not installed, so no QR code was printed (Debian/Ubuntu: apt install qrencode).\n'
|
||||||
|
printf ' Transfer %s to the device by another route instead.\n' "$PROFILE"
|
||||||
|
fi
|
||||||
@@ -101,20 +101,124 @@ spec:
|
|||||||
enabled: true
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
|
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
|
||||||
|
# --- STATIC FLOW EXPORT: persist the PRE-SNAT client address for Plex (2026-10-04) ---
|
||||||
|
# [parity] MUST equal Ansible: hubble.export.static.{enabled,filePath,allowList}
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. Plex's own log cannot show who connected. Every LoadBalancer Service in
|
||||||
|
# this cluster is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the
|
||||||
|
# VIP SNATs the client before forwarding to the backend pod. Measured 2026-10-04: Plex had
|
||||||
|
# logged 3,214 requests from `10.0.1.x` (the lease holder k3s-prod-3's own address, Cilium
|
||||||
|
# identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No X-Forwarded-For
|
||||||
|
# either -- it serves raw tcp/32400, not HTTP through the Gateway.
|
||||||
|
#
|
||||||
|
# THE ADDRESS IS NOT LOST, ONLY UNRECORDED. Cilium observes the flow at ingress, BEFORE its
|
||||||
|
# own translation, so Hubble already has the true source. Measured on the lease holder:
|
||||||
|
# 192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
|
||||||
|
# 10.0.1.78 -> 10.0.0.135 (the same traffic, post-SNAT -- what Plex sees)
|
||||||
|
# What was missing is RETENTION: the ring buffer was 4095/4095 flows at ~303 flows/s, i.e.
|
||||||
|
# about THIRTEEN SECONDS of history, with hubble metrics disabled and no exporter configured.
|
||||||
|
#
|
||||||
|
# WHY NOT FIX THE SNAT INSTEAD. `externalTrafficPolicy: Local` would preserve the address in
|
||||||
|
# Plex's own log, but cilium 1.20's L2 announcer is explicitly incompatible with it -- upstream
|
||||||
|
# docs for this exact version: "incompatible with the externalTrafficPolicy: Local on services
|
||||||
|
# as it may cause service IPs to be announced on nodes without pods causing traffic drops",
|
||||||
|
# and "L2 announcements isn't currently aware of this setting". Live proof that this is not
|
||||||
|
# theoretical: the VIP is announced by k3s-prod-3 while the pod runs on k3s-prod-1, so the
|
||||||
|
# flip would black-hole tcp/32400 immediately. Making it safe would mean pinning BOTH the pod
|
||||||
|
# and the announcement to one node and giving up Plex's ability to reschedule -- a real
|
||||||
|
# availability loss to buy a log field. (It is the same trap platform/crafty/service-l4.yaml
|
||||||
|
# documents from an 8-day Minecraft outage.) DSR was also rejected: routing-mode here is
|
||||||
|
# vxlan, not native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO
|
||||||
|
# router's stateful NAT on the remote path.
|
||||||
|
#
|
||||||
|
# `filePath: stdout` RATHER THAN A FILE, deliberately. Grafana Alloy already runs as a
|
||||||
|
# DaemonSet tailing every pod's logs into Loki (platform/monitoring/alloy.yaml), so stdout
|
||||||
|
# reaches Loki with NO new hostPath mount, no file-rotation handling and no Alloy change.
|
||||||
|
# Upstream documents it: "To get Hubble flows directly exported to the logs instead of written
|
||||||
|
# to a rotated file, `stdout` can be defined as `hubble-export-file-path`." fileMaxSizeMb /
|
||||||
|
# fileMaxBackups are therefore left at chart defaults and do not apply.
|
||||||
|
# Query: {namespace="kube-system", app="cilium"} |= "32400"
|
||||||
|
#
|
||||||
|
# SCOPED BY PORT, NOT BY NAMESPACE -- and that is a measured decision, not a preference. A
|
||||||
|
# `--to-namespace plex` query returned ONLY the post-SNAT flows; the flow carrying the real
|
||||||
|
# client address did NOT match it, because at the point of observation the destination
|
||||||
|
# identity is not yet resolved to the plex pod. Filtering by namespace would therefore drop
|
||||||
|
# exactly the record this change exists to capture. Port it is.
|
||||||
|
#
|
||||||
|
# Both directions are listed so the return path is captured too. Volume is small (a few
|
||||||
|
# hundred to a few thousand lines a day); if it ever becomes noisy, narrow it with
|
||||||
|
# `fieldMask` rather than by removing a filter.
|
||||||
|
export:
|
||||||
|
static:
|
||||||
|
enabled: true
|
||||||
|
filePath: stdout
|
||||||
|
allowList:
|
||||||
|
- '{"destination_port":["32400"]}'
|
||||||
|
- '{"source_port":["32400"]}'
|
||||||
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
|
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
|
||||||
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
||||||
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
|
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
|
||||||
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
|
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
|
||||||
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
|
#
|
||||||
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
|
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
|
||||||
|
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
|
||||||
|
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
|
||||||
|
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
|
||||||
|
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
|
||||||
|
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
|
||||||
|
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
|
||||||
|
# only the agent. Every other host-netns process (the k3s apiserver in
|
||||||
|
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
|
||||||
|
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
|
||||||
|
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
|
||||||
|
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
|
||||||
|
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
|
||||||
|
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
|
||||||
|
# FAILED from the host, same node, same netns. Full write-up:
|
||||||
|
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
|
||||||
|
#
|
||||||
|
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
|
||||||
|
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
|
||||||
|
# stays off because the mount it would create already exists.
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||||
|
# --- Roll the agents when their ConfigMap changes (2026-10-05) ---
|
||||||
|
# [parity] MUST equal Ansible: rollOutCiliumPods: true
|
||||||
|
#
|
||||||
|
# WHY: a merged Cilium config change DID NOTHING. The chart default is `false`
|
||||||
|
# (cilium 1.20.1 values.yaml: "Roll out cilium agent pods automatically when configmap is
|
||||||
|
# updated. rollOutCiliumPods: false"), so Helm rewrites `cilium-config` and leaves the
|
||||||
|
# DaemonSet's pod template untouched — and cilium-agent reads that ConfigMap only at STARTUP.
|
||||||
|
# The result is a silently inert change: Flux green, HelmRelease `UpgradeSucceeded`, the new
|
||||||
|
# keys visibly present in the ConfigMap, and the running agents still on the old settings
|
||||||
|
# until a human remembers to restart them.
|
||||||
|
#
|
||||||
|
# MEASURED, and it is why this exists: the Hubble flow exporter merged 2026-10-04 wrote
|
||||||
|
# `hubble-export-file-path: stdout` and its allowlist into `cilium-config`, the HelmRelease
|
||||||
|
# reported `Helm upgrade succeeded ... cilium.v6`, and over a DAY later all three agents were
|
||||||
|
# still the pre-merge pods (startTime 2026-10-04T07:11Z) with ZERO exported flow lines. The
|
||||||
|
# feature was merged, believed delivered, and doing nothing at all. That is the same class of
|
||||||
|
# failure this repo already documents elsewhere — a green signal over something that never ran.
|
||||||
|
#
|
||||||
|
# THE COST IS REAL AND IS THE POINT: every future Cilium config change now triggers a
|
||||||
|
# cluster-wide agent rollout instead of waiting for a manual restart. That is strictly more
|
||||||
|
# honest — the change applies when it is merged, or it fails visibly — but it means config
|
||||||
|
# edits are disruptive by default, so they want a quiet moment. One node at a time:
|
||||||
|
# `updateStrategy.rollingUpdate.maxUnavailable: 1` below means the DaemonSet will not proceed
|
||||||
|
# past an unready agent, so a bad render costs one node rather than the cluster. Note that the
|
||||||
|
# node holding the L2 lease for the Gateway, PiHole-DNS and Plex VIPs loses them briefly as it
|
||||||
|
# cycles, so a rollout can blip LAN DNS.
|
||||||
|
#
|
||||||
|
# ENABLING THIS IS ITSELF ONE ROLLOUT: adding the ConfigMap checksum annotation changes the pod
|
||||||
|
# template, so merging this rolls the agents once — which is what finally activates the Hubble
|
||||||
|
# exporter that has been inert since 2026-10-04.
|
||||||
|
rollOutCiliumPods: true
|
||||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||||
# maxUnavailable: 2) — NOT a local choice, and on a 3-node cluster that lets a single bad
|
# maxUnavailable: 2) — NOT a local choice, and on a 3-node cluster that lets a single bad
|
||||||
|
|||||||
@@ -32,20 +32,43 @@ enableIPv4Masquerade: true
|
|||||||
bpf:
|
bpf:
|
||||||
masquerade: true
|
masquerade: true
|
||||||
# [parity] flow observability / isolation audit
|
# [parity] flow observability / isolation audit
|
||||||
|
# [parity] roll the agents when cilium-config changes. The chart default (false) rewrites the
|
||||||
|
# ConfigMap but leaves the pod template alone, and cilium-agent only reads it at STARTUP — so a
|
||||||
|
# merged config change is SILENTLY INERT. Measured 2026-10-04..05: the Hubble exporter keys were
|
||||||
|
# live in cilium-config and the HelmRelease said UpgradeSucceeded, while all three agents were
|
||||||
|
# still pre-merge pods exporting nothing for over a day. Cost: config edits now roll the agents
|
||||||
|
# (one node at a time via maxUnavailable: 1), which can briefly blip the VIPs on the lease-holding
|
||||||
|
# node. Full rationale in helmrelease.yaml.
|
||||||
|
rollOutCiliumPods: true
|
||||||
|
|
||||||
hubble:
|
hubble:
|
||||||
enabled: true
|
enabled: true
|
||||||
relay:
|
relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
# [parity] static flow export -> cilium-agent stdout -> Alloy -> Loki. Records the PRE-SNAT
|
||||||
|
# client address for Plex's raw tcp/32400, which Plex's own log cannot show (every LB Service here
|
||||||
|
# is externalTrafficPolicy: Cluster, so the VIP lease holder SNATs the client). Scoped BY PORT,
|
||||||
|
# not namespace: the flow carrying the real client IP does not match a namespace filter. Full
|
||||||
|
# rationale, measurements and the rejected alternatives are in helmrelease.yaml.
|
||||||
|
export:
|
||||||
|
static:
|
||||||
|
enabled: true
|
||||||
|
filePath: stdout
|
||||||
|
allowList:
|
||||||
|
- '{"destination_port":["32400"]}'
|
||||||
|
- '{"source_port":["32400"]}'
|
||||||
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
|
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
|
||||||
|
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
|
||||||
|
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
|
||||||
cgroup:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
|
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
|
||||||
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
|
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
|
||||||
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ A wildcard `*.keithjackson.dev` matches only one label deep, so `<svc>.home.keit
|
|||||||
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
|
its own record. **`internal` services need NO public record** — PiHole resolves them to the internal
|
||||||
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
|
Gateway VIP (split-horizon). ExternalDNS manages **`public`** services' records only.
|
||||||
|
|
||||||
|
## Public target + in-cluster DDNS (2026-10-03)
|
||||||
|
|
||||||
|
Gateway-published records are **CNAMEs to `wan.home.keithjackson.dev`**, not the Gateway VIP:
|
||||||
|
|
||||||
|
```
|
||||||
|
plex.home.keithjackson.dev CNAME wan.home.keithjackson.dev <- external-dns (Gateway `target` annotation)
|
||||||
|
wan.home.keithjackson.dev A <current WAN IP> <- cloudflare-ddns (ddns.yaml), every 5m
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `target` annotation is on the **shared Gateway** (`platform/gateway/gateway.yaml`), so it
|
||||||
|
applies to every route that publishes at all. `internal` routes (`controller: none`) still publish
|
||||||
|
nothing.
|
||||||
|
- `ddns.yaml` runs `favonia/cloudflare-ddns` in this namespace with the same `cloudflare-api-token`.
|
||||||
|
It detects the WAN IP through Cloudflare's trace endpoint, so the pod **must not** carry
|
||||||
|
`egress-class: vpn`.
|
||||||
|
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
|
||||||
|
(split-horizon) and never use the public path.
|
||||||
|
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node, the apiserver's
|
||||||
|
OIDC issuer `keycloak.home.keithjackson.dev` resolves to the WAN IP. kubectl SSO therefore relies on
|
||||||
|
the router hairpinning (NAT reflection) WAN:443 back to the Gateway.
|
||||||
|
|
||||||
|
Check: `dig +short @1.1.1.1 plex.home.keithjackson.dev` → `wan.home.keithjackson.dev.` then the WAN IP;
|
||||||
|
`kubectl -n external-dns logs deploy/cloudflare-ddns`.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
|
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
# In-cluster dynamic DNS for the home WAN (2026-10-03).
|
||||||
|
#
|
||||||
|
# Keeps ONE record, `wan.${BASE_DOMAIN}` (A, IPv4 only, not proxied), pointed at the home WAN's
|
||||||
|
# current public IP. Every public name external-dns publishes from the shared Gateway is a CNAME to
|
||||||
|
# it (platform/gateway/gateway.yaml `external-dns.alpha.kubernetes.io/target`), so a WAN IP change
|
||||||
|
# moves all of them through this one record, with no Git change and no external-dns involvement.
|
||||||
|
#
|
||||||
|
# OWNERSHIP SPLIT WITH EXTERNAL-DNS. This record carries no external-dns TXT registry entry, so
|
||||||
|
# external-dns (policy: sync, owner `prod`) never treats it as its own and never deletes it.
|
||||||
|
# Conversely this updater manages only the names in DOMAINS and never touches external-dns's records.
|
||||||
|
#
|
||||||
|
# IP DETECTION. Default `cloudflare.trace`: an HTTPS request to Cloudflare's trace endpoint, which
|
||||||
|
# reports the address the request arrived from. Correct here only because this pod is NOT in the
|
||||||
|
# `vpn` egress class — the CiliumEgressGatewayPolicy (platform/vpn-egress) selects pods by
|
||||||
|
# `homelab.keithjackson.dev/egress-class: vpn` alone, cluster-wide. Labelling this pod `vpn` would
|
||||||
|
# publish the VPN exit IP instead of the WAN IP.
|
||||||
|
#
|
||||||
|
# TOKEN. Reuses this namespace's `cloudflare-api-token` (same Zone:DNS:Edit token as external-dns
|
||||||
|
# and cert-manager) — no new secret.
|
||||||
|
#
|
||||||
|
# TIER NOTE. This lives in the gating `platform` Flux Kustomization (wait: true) next to
|
||||||
|
# external-dns. If it ever crashloops, `platform` goes not-Ready and apps stop reconciling (no
|
||||||
|
# outage). Kept here rather than in its own non-gating Kustomization to avoid a clusters/** change.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cloudflare-ddns
|
||||||
|
namespace: external-dns
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
spec:
|
||||||
|
replicas: 1 # exactly one writer for the record
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
strategy:
|
||||||
|
type: Recreate # never two updaters at once
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: cloudflare-ddns
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: cloudflare-ddns
|
||||||
|
image: favonia/cloudflare-ddns:1.17.1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: CLOUDFLARE_API_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: cloudflare-api-token
|
||||||
|
key: api-token
|
||||||
|
- name: DOMAINS
|
||||||
|
value: "wan.${BASE_DOMAIN}"
|
||||||
|
- name: IP6_PROVIDER
|
||||||
|
value: "none" # IPv4 WAN only; leaves any AAAA alone
|
||||||
|
- name: PROXIED
|
||||||
|
value: "false" # DNS-only: Plex/Gitea/etc. terminate TLS on the Gateway
|
||||||
|
- name: UPDATE_CRON
|
||||||
|
value: "@every 5m"
|
||||||
|
- name: DELETE_ON_STOP
|
||||||
|
value: "false" # a pod restart must not blank the record every CNAME depends on
|
||||||
|
- name: RECORD_COMMENT
|
||||||
|
value: "managed by k3s cloudflare-ddns (platform/externaldns/ddns.yaml)"
|
||||||
|
- name: EMOJI
|
||||||
|
value: "false"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 5m
|
||||||
|
memory: 16Mi
|
||||||
|
limits:
|
||||||
|
memory: 64Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
@@ -81,38 +81,29 @@ spec:
|
|||||||
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
|
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
|
||||||
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
|
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
|
||||||
# ---------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------
|
||||||
# THE PUBLIC-TARGET GAP — STILL OPEN, AND NOT FIXABLE HERE ON v0.21.0 (operator decision)
|
# THE PUBLIC TARGET — DECIDED 2026-10-03 (operator): every Gateway-published name goes public
|
||||||
#
|
#
|
||||||
# The five `exposure: public` services (plex, gitea, seerr, keycloak, nextcloud) publish records
|
# Until 2026-10-03 every gateway-sourced record answered with the Gateway VIP (192.168.2.20,
|
||||||
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
|
# RFC1918), so nothing published here was reachable from the internet. Two facts constrained the
|
||||||
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
|
# fix, and both still hold:
|
||||||
# this change, for two independent reasons:
|
# * `external-dns.alpha.kubernetes.io/target` is read in v0.21.0's gateway-httproute source ONLY
|
||||||
|
# from the GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
|
||||||
|
# gw.Annotations)`) and from a ListenerSet listener — NEVER from an HTTPRoute. A target on the
|
||||||
|
# shared Gateway therefore moves EVERY gateway-sourced record at once.
|
||||||
|
# * The home WAN IP is residential and can change, so a literal IP in Git would rot.
|
||||||
#
|
#
|
||||||
# 1. THE VALUE IS NOT ESTABLISHED. The one public IP this repo knows is 136.51.115.252
|
# The operator's answers: (a) yes, ALL of them — the five `exposure: public` apps AND the admin
|
||||||
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
|
# UIs (auth, crafty, grafana, headlamp, the apex), which sit behind Keycloak / oauth2-proxy;
|
||||||
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
|
# (b) the address is kept current IN-CLUSTER. Implementation:
|
||||||
# certificate. Whether prod's public services belong behind it, behind a new forward, or
|
# * platform/gateway/gateway.yaml sets `target: wan.${BASE_DOMAIN}` on the shared Gateway, so
|
||||||
# are not meant to be public at all is an operator decision. Breaking five services' DNS
|
# each published name becomes a CNAME to that one hostname;
|
||||||
# on a guess is worse than leaving a known-wrong-but-stable state.
|
# * platform/externaldns/ddns.yaml (favonia/cloudflare-ddns) keeps `wan.${BASE_DOMAIN}` an A
|
||||||
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
|
# record at the current WAN IP. That record has no external-dns TXT registry entry, so this
|
||||||
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
|
# instance never owns or garbage-collects it.
|
||||||
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
|
# Internal services are unchanged: their routes carry `controller: none` (above), which is
|
||||||
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
|
# checked BEFORE the target is read, so they still publish nothing. The `service` source (the L4
|
||||||
# no per-service target override while every public service shares the `shared` Gateway.
|
# LoadBalancers crafty-mc, unifi) does not read the Gateway's annotation and still publishes its
|
||||||
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
|
# LB VIP. Whether those should go public is a separate, still-open question.
|
||||||
# still includes auth, crafty, grafana, headlamp and the apex — i.e. it would publish a
|
|
||||||
# routable address for admin UIs. That is strictly worse than the status quo.
|
|
||||||
#
|
|
||||||
# SO THE DECISION THE OPERATOR OWNS IS TWO-PART, and no knob is added here until the first half
|
|
||||||
# is answered, because a knob that cannot take effect invites someone to set it and expect one:
|
|
||||||
# (a) WHAT address should the five public names answer with — 136.51.115.252 as-is, a new
|
|
||||||
# port-forward/WAN address, or nothing (drop `exposure: public` to `internal` instead)?
|
|
||||||
# (b) HOW should they be separated from the non-public names that share the Gateway? Options,
|
|
||||||
# roughly in ascending cost: a SECOND Gateway (or a ListenerSet) carrying the target
|
|
||||||
# annotation, with only the public services' routes attached; or publishing the five names
|
|
||||||
# from the `crd` source (DNSEndpoint objects the renderer emits for `public` services),
|
|
||||||
# which restores per-service targeting without a second Gateway.
|
|
||||||
# Only (b) is a manifest change; (a) is a fact about the home WAN that has to be checked.
|
|
||||||
# ---------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------
|
||||||
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
|
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ resources:
|
|||||||
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
|
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
|
||||||
# the external-dns namespace. Flux decrypts at reconcile.
|
# the external-dns namespace. Flux decrypts at reconcile.
|
||||||
- cloudflare-api-token.sops.yaml
|
- cloudflare-api-token.sops.yaml
|
||||||
|
# DDNS for `wan.${BASE_DOMAIN}` — the CNAME target every Gateway-published record points at.
|
||||||
|
- ddns.yaml
|
||||||
@@ -7,6 +7,23 @@ kind: Gateway
|
|||||||
metadata:
|
metadata:
|
||||||
name: shared
|
name: shared
|
||||||
namespace: gateway
|
namespace: gateway
|
||||||
|
annotations:
|
||||||
|
# PUBLIC DNS TARGET (2026-10-03, operator decision). Without this, external-dns publishes every
|
||||||
|
# route on this Gateway with the Gateway's OWN address (${GATEWAY_VIP}, RFC1918), which is
|
||||||
|
# unreachable from the internet. With it, every published `<svc>.${BASE_DOMAIN}` (and the apex)
|
||||||
|
# becomes a CNAME to `wan.${BASE_DOMAIN}`, an A record that platform/externaldns/ddns.yaml keeps
|
||||||
|
# pointed at the home WAN's current public IP. The WAN forwards 80/443 to ${GATEWAY_VIP}.
|
||||||
|
#
|
||||||
|
# Scope is EVERY route on this Gateway that publishes at all — the operator chose to make the
|
||||||
|
# admin UIs (auth, grafana, headlamp, crafty, apex) publicly resolvable alongside the five
|
||||||
|
# `exposure: public` apps. `internal` services stay unpublished via their
|
||||||
|
# `external-dns.alpha.kubernetes.io/controller: none` route annotation, which external-dns checks
|
||||||
|
# BEFORE it reads this target. external-dns v0.21.0 reads `target` from the Gateway only, never
|
||||||
|
# from an HTTPRoute — see platform/externaldns/helmrelease.yaml.
|
||||||
|
#
|
||||||
|
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
|
||||||
|
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
|
||||||
|
external-dns.alpha.kubernetes.io/target: "wan.${BASE_DOMAIN}"
|
||||||
spec:
|
spec:
|
||||||
gatewayClassName: cilium
|
gatewayClassName: cilium
|
||||||
listeners:
|
listeners:
|
||||||
|
|||||||
@@ -88,3 +88,128 @@ This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on
|
|||||||
3. Log in as a **non-platform-admins** user -> expect **403** (authenticated but not authorized).
|
3. Log in as a **non-platform-admins** user -> expect **403** (authenticated but not authorized).
|
||||||
4. Confirm the session cookie is `.home.keithjackson.dev`-scoped (single sign-on across the other
|
4. Confirm the session cookie is `.home.keithjackson.dev`-scoped (single sign-on across the other
|
||||||
protected UIs).
|
protected UIs).
|
||||||
|
|
||||||
|
## Observability: what this component can and cannot see (measured 2026-09-30)
|
||||||
|
|
||||||
|
> **SUPERSEDED — read "Update 2026-10-05" at the end of this section first.** The gate sidecars
|
||||||
|
> **are** scraped now and the TSDB **does** hold `oauth2_proxy_*` for them. The two subsections
|
||||||
|
> below are kept as the dated record of how this was diagnosed, not as current state.
|
||||||
|
|
||||||
|
`servicemonitor.yaml` scrapes **this namespace's** deployment and nothing else. On prod that is
|
||||||
|
two targets, `job="oauth2-proxy"`, both `up=1`. The complete set of oauth2-proxy-specific series
|
||||||
|
in the TSDB is:
|
||||||
|
|
||||||
|
count by (__name__) ({__name__=~"oauth2_proxy.*"})
|
||||||
|
oauth2_proxy_requests_in_flight => 2
|
||||||
|
|
||||||
|
**The per-app gate sidecars are not scraped, and there is nothing to scrape.** Six apps run an
|
||||||
|
`oauth2-proxy` container of their own — crafty, jackett, pihole, qbittorrent, radarr, sonarr —
|
||||||
|
each with `--http-address=0.0.0.0:4180` and **no `--metrics-address`**, so oauth2-proxy's metrics
|
||||||
|
listener never starts. They declare only a `http:4180` container port, no `metrics` port exists on
|
||||||
|
their Services, and `kubectl get servicemonitors,podmonitors -A` selects none of them. Any alert
|
||||||
|
written against `oauth2_proxy_requests_total{namespace="jackett"}` would be structurally inert.
|
||||||
|
|
||||||
|
This matters because the gate's characteristic failure is invisible by construction: an
|
||||||
|
unauthenticated **XHR** gets a `302` to Keycloak rather than a `401`, the browser follows it
|
||||||
|
against a live SSO session with no prompt, and the call resolves **200 carrying an HTML page**
|
||||||
|
where the app expected JSON. Nothing in the request path reports an error. On 2026-09-30 jackett
|
||||||
|
spent a burst of requests in exactly that state, re-established a session, and lost it 26 seconds
|
||||||
|
into a 5-minute session, while `jackett-down` sat at 1 replica available the whole time.
|
||||||
|
|
||||||
|
`auth-visibility-alerts.yaml` therefore alerts on **the gap**, not on the incident:
|
||||||
|
`OAuth2ProxyGateUnobserved` compares `kube_pod_container_info{container="oauth2-proxy"}` against
|
||||||
|
`up{container="oauth2-proxy"}` — two already-scraped families — and names every namespace running
|
||||||
|
a gate that nothing observes. It fires for six namespaces today and clears per-namespace as each
|
||||||
|
one gains a scrape. `runbooks/oauth2-proxy-auth-failures.md` is the by-hand substitute meanwhile,
|
||||||
|
and it reads the sidecar logs out of Loki (Alloy collects them; 72h retention).
|
||||||
|
|
||||||
|
**To close the gap for an app**, three things are needed together, on that app's own manifest:
|
||||||
|
`--metrics-address=0.0.0.0:44180` on the sidecar, a `metrics` containerPort plus the matching
|
||||||
|
Service port, and a ServiceMonitor selecting it. Only then does the auth-churn rule — sustained
|
||||||
|
`code="302"` as a share of `oauth2_proxy_requests_total`, held across a window longer than the
|
||||||
|
session lifetime — become writable rather than decorative.
|
||||||
|
|
||||||
|
### Update 2026-10-01: the listener exists; the scrape path does not
|
||||||
|
|
||||||
|
PR #28 added `--metrics-address=0.0.0.0:44180` and a named `metrics` container port to the five
|
||||||
|
fleet sidecars (jackett, radarr, sonarr, qbittorrent, pihole). `podmonitor.yaml` in this directory
|
||||||
|
discovers them by the pod label `homelab.keithjackson.dev/auth-mode: forward-auth` — a **PodMonitor**
|
||||||
|
rather than a ServiceMonitor because no Service exposes 44180 and adding one would mean changing the
|
||||||
|
renderer.
|
||||||
|
|
||||||
|
The endpoint is real: port-forwarded straight to the jackett pod, `/metrics` returns **http=200,
|
||||||
|
10208 bytes**, exporting `oauth2_proxy_requests_in_flight`, `oauth2_proxy_requests_total` (with
|
||||||
|
`code` labels) and `oauth2_proxy_response_duration_seconds`. That is verified by payload, not by the
|
||||||
|
flag appearing in `args` — a distinction worth keeping, because the two claims came apart here.
|
||||||
|
|
||||||
|
**The scrape is nevertheless dropped.** From a pod in ns `monitoring`, both `:44180` and `:4180` on
|
||||||
|
the jackett pod time out; the shared proxy's `:44180` answers. The only difference is that ns
|
||||||
|
`oauth2-proxy` has **no NetworkPolicy at all**, while each app namespace has a `default-deny` whose
|
||||||
|
`policyTypes` include `Ingress` with no `ingress` rules (deny-all) plus a single CNP admitting
|
||||||
|
`fromEntities: [ingress]` to port 4180 — the Gateway's Envoy and nothing else. Prometheus matches no
|
||||||
|
clause.
|
||||||
|
|
||||||
|
So the targets read `up == 0` and the TSDB is still empty for those namespaces. This is the same bug
|
||||||
|
and the same fingerprint as `platform/longhorn/networkpolicy-metrics.yaml` documents for Longhorn,
|
||||||
|
and the same narrow fix applies: ns `monitoring` → the gate pod label → TCP 44180 only. It cannot
|
||||||
|
ship from this directory, because `oauth2-proxy` and `apps-wave-0` are **siblings** in the Flux graph
|
||||||
|
(both `dependsOn: [platform-config]`), so a NetworkPolicy naming ns `jackett` would apply on today's
|
||||||
|
cluster and fail on a cold rebuild. It belongs with each app's own rendered policy.
|
||||||
|
|
||||||
|
Consequence for `auth-visibility-alerts.yaml`: its `unless` clause requires `up == 1`, not merely a
|
||||||
|
present `up` series. A discovered-but-failing target publishes `up` with value 0, so the bare form
|
||||||
|
would have cleared `OAuth2ProxyGateUnobserved` the moment the PodMonitor landed — reporting the gates
|
||||||
|
as observed while nothing was being scraped. The unit test pins that scenario; deleting the `== 1`
|
||||||
|
fails it and nothing else.
|
||||||
|
|
||||||
|
### Update 2026-10-05: the scrape works
|
||||||
|
|
||||||
|
PR #49 (merged `21c88b0`) added the missing third piece. `catalog/render.py`'s
|
||||||
|
`r_monitoring_ingress_policy` now also fires for the gate sidecar's metrics port, keyed on
|
||||||
|
`_needs_oauth2_sidecar`, rendering `<app>-allow-monitoring-scrape` per app: ns `monitoring` → that
|
||||||
|
app's `endpointSelector` → TCP 44180 only. Five objects, one each for jackett, pihole, qbittorrent,
|
||||||
|
radarr, sonarr.
|
||||||
|
|
||||||
|
Measured on prod 2026-10-05 after Flux applied it (`apps-wave-0` 21:37:01Z, `apps-wave-1`
|
||||||
|
21:37:46Z):
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| target health (6) | `down` | `up` |
|
||||||
|
| `lastError` | `context deadline exceeded` | `''` |
|
||||||
|
| `lastScrapeDuration` | **10.0s** — exactly the `scrapeTimeout` | **0.001–0.006s** |
|
||||||
|
| `up` | `0` × 6 | `1` × 6 |
|
||||||
|
|
||||||
|
count by (__name__) ({__name__=~"oauth2_proxy_.*",
|
||||||
|
namespace=~"jackett|radarr|sonarr|qbittorrent|pihole"})
|
||||||
|
oauth2_proxy_requests_in_flight => 6
|
||||||
|
oauth2_proxy_requests_total => 16
|
||||||
|
oauth2_proxy_response_duration_seconds_bucket => 108
|
||||||
|
oauth2_proxy_response_duration_seconds_sum => 9
|
||||||
|
oauth2_proxy_response_duration_seconds_count => 9
|
||||||
|
|
||||||
|
sum by (namespace) (oauth2_proxy_requests_total)
|
||||||
|
qbittorrent => 35072 radarr => 23560 sonarr => 1284
|
||||||
|
|
||||||
|
`oauth2_proxy_requests_total` is absent for jackett and pihole simply because that counter is not
|
||||||
|
created until the gate has served its first request.
|
||||||
|
|
||||||
|
**So the auth-churn rule is now writable rather than decorative** — sustained `code="302"` as a
|
||||||
|
share of `oauth2_proxy_requests_total`, held across a window longer than the session lifetime. That
|
||||||
|
is the rule the 2026-09-30 incident above could not be caught by. It is still unwritten.
|
||||||
|
|
||||||
|
**Alert outcome:** all 5 `TargetDown` resolved, and 5 of 6 `OAuth2ProxyGateUnobserved` with them.
|
||||||
|
At `repeat_interval: 12h` with `group_by: [alertname, namespace]`, those 11 groups had been
|
||||||
|
re-notifying at roughly **22 emails/day** since 2026-10-02; that drops to 2/day.
|
||||||
|
|
||||||
|
**`crafty` still fires, and that is correct.** It is a hand-maintained Deployment under
|
||||||
|
`platform/crafty`, outside the renderer, whose `oauth2-proxy` container has **no
|
||||||
|
`--metrics-address` at all** — no listener, so no port to open and nothing for the PodMonitor's
|
||||||
|
pod-label selector to match. Closing it needs the flag and a `metrics` containerPort on crafty's
|
||||||
|
own manifest first, then a hand-written allowance beside it. Open follow-up work.
|
||||||
|
|
||||||
|
**The paragraph above headed "To close the gap for an app" is superseded**: a *Service* port and a
|
||||||
|
ServiceMonitor are **not** required. A PodMonitor targets pod ports directly, which is why no
|
||||||
|
Service exposes 44180 and why the renderer needed no Service change. The three things actually
|
||||||
|
needed are `--metrics-address` + named container port, a PodMonitor, and the monitoring-ingress
|
||||||
|
allowance.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
---
|
||||||
|
# promtool unit tests for OAuth2ProxyGateUnobserved (platform/oauth2-proxy/auth-visibility-alerts.yaml).
|
||||||
|
#
|
||||||
|
# WHY. The rule is an `unless` between two aggregations over DIFFERENT metric families, and the
|
||||||
|
# failure modes of that shape are both silent: a label that does not survive `count by` makes the
|
||||||
|
# right-hand side match nothing (the alert fires for the scraped namespace too, i.e. permanently),
|
||||||
|
# and a right-hand side that over-matches makes it fire for nobody (i.e. inert). Neither is
|
||||||
|
# visible by reading the expression, and neither is visible on a live cluster where the answer
|
||||||
|
# happens to look plausible. So all four states are pinned: fires for an unscraped namespace,
|
||||||
|
# stays quiet for a scraped one, honours `for: 1h`, and STOPS once a scrape appears.
|
||||||
|
#
|
||||||
|
# HOW TO RUN. The one repo-wide harness, by the companion convention:
|
||||||
|
# ./infra/devcontainer/dev.sh run bash -c 'platform/monitoring/tests/run.sh platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml'
|
||||||
|
# promtool is pinned in infra/devcontainer/versions.env; never run it off the host PATH.
|
||||||
|
#
|
||||||
|
# THE SERIES BELOW ARE REAL SHAPES read off live prod Prometheus on 2026-09-30 before being
|
||||||
|
# copied here — kube_pod_container_info as kube-state-metrics emits it (job="kube-state-metrics",
|
||||||
|
# its own instance/pod labels, image/image_id/container_id) and `up` as the oauth2-proxy
|
||||||
|
# ServiceMonitor emits it (endpoint="metrics", port 44180). The point of using the real shapes is
|
||||||
|
# that `count by (namespace)` has to discard all of them; a test with tidy two-label series would
|
||||||
|
# pass while the real expression collapsed differently.
|
||||||
|
#
|
||||||
|
# ASSERTIONS USE `ALERTS` RATHER THAN exp_alerts: the description annotation is long prose and
|
||||||
|
# comparing it verbatim would couple this file to every wording change while proving nothing the
|
||||||
|
# ALERTS series does not already prove.
|
||||||
|
rule_files:
|
||||||
|
- auth-visibility-alerts.rules.yaml
|
||||||
|
evaluation_interval: 1m
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 1. THE STATE PROD IS ACTUALLY IN (2026-09-30): jackett runs a gate sidecar with no scrape;
|
||||||
|
# the shared oauth2-proxy namespace runs two that ARE scraped. Asserts the alert fires for
|
||||||
|
# jackett and NOT for oauth2-proxy, and that `for: 1h` is honoured (pending at 59m).
|
||||||
|
- interval: 1m
|
||||||
|
name: an unscraped gate namespace fires after 1h; the scraped one never does
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
|
||||||
|
values: '1x130'
|
||||||
|
# The shared instance: kube-state-metrics sees both replicas...
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://aa11bb22",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-cqz27",service="kube-prometheus-stack-kube-state-metrics",uid="1111aaaa-0000-0000-0000-000000000001"}'
|
||||||
|
values: '1x130'
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://cc33dd44",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-bscxg",service="kube-prometheus-stack-kube-state-metrics",uid="1111aaaa-0000-0000-0000-000000000002"}'
|
||||||
|
values: '1x130'
|
||||||
|
# ...and both replicas are genuinely scraped, so `up` carries container="oauth2-proxy" there.
|
||||||
|
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.189:44180",job="oauth2-proxy",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-cqz27",service="oauth2-proxy"}'
|
||||||
|
values: '1x130'
|
||||||
|
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.2.47:44180",job="oauth2-proxy",namespace="oauth2-proxy",pod="oauth2-proxy-7568c78795-bscxg",service="oauth2-proxy"}'
|
||||||
|
values: '1x130'
|
||||||
|
promql_expr_test:
|
||||||
|
# Not yet firing at 59m — `for: 1h` has not elapsed. Guards against someone "simplifying"
|
||||||
|
# the hold away and turning every rollout into a page.
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing"}'
|
||||||
|
eval_time: 59m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="pending",namespace="jackett"}'
|
||||||
|
eval_time: 59m
|
||||||
|
exp_samples:
|
||||||
|
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="pending",namespace="jackett",severity="warning"}'
|
||||||
|
value: 1
|
||||||
|
# Firing for jackett past the hold...
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing"}'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples:
|
||||||
|
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
|
||||||
|
value: 1
|
||||||
|
# ...and never for the namespace that IS scraped. This is the assertion that catches an
|
||||||
|
# `unless` whose right-hand side stopped matching: without it, a broken rule that fires for
|
||||||
|
# every gate namespace would pass the test above.
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="oauth2-proxy"}'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples: []
|
||||||
|
# The count must be 1 per namespace-with-a-gate, not 1 overall: a `count by (namespace)`
|
||||||
|
# that lost its `by` would collapse to a single unlabelled series.
|
||||||
|
- expr: 'count by (namespace) (kube_pod_container_info{container="oauth2-proxy"}) unless count by (namespace) (up{container="oauth2-proxy"})'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{namespace="jackett"}'
|
||||||
|
value: 1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 2. THE FIX LANDING. jackett's sidecar gains --metrics-address and a ServiceMonitor 70m in, so
|
||||||
|
# `up` starts carrying container="oauth2-proxy" for namespace="jackett" under a DIFFERENT job
|
||||||
|
# name than the shared proxy's. The alert must stop. This is what makes the rule a gap
|
||||||
|
# tracker rather than a permanent nag, and it pins that the match is on `container`, not on
|
||||||
|
# a hardcoded job="oauth2-proxy" (which would keep firing forever after a correct fix).
|
||||||
|
- interval: 1m
|
||||||
|
name: the alert clears once the sidecar is actually scraped
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
|
||||||
|
values: '1x130'
|
||||||
|
# Absent for the first 70 minutes, then present. `_x` is "no sample"; the scrape did not
|
||||||
|
# exist yet.
|
||||||
|
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.213:44180",job="jackett-auth",namespace="jackett",pod="jackett-c5bfcbfb8-c75f9",service="jackett-auth-metrics"}'
|
||||||
|
values: '_x70 1x60'
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett"}'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples:
|
||||||
|
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
|
||||||
|
value: 1
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="jackett"}'
|
||||||
|
eval_time: 80m
|
||||||
|
exp_samples: []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 3. THE REGRESSION GUARD FOR `== 1` — the scenario that exposed a real bug on 2026-10-01.
|
||||||
|
#
|
||||||
|
# A target is DISCOVERED but every scrape FAILS: `up` exists with value 0. This is not
|
||||||
|
# hypothetical — it is the exact state podmonitor.yaml creates today, because the app
|
||||||
|
# namespaces' default-deny drops the scrape (measured; see podmonitor.yaml).
|
||||||
|
#
|
||||||
|
# With the original `unless count by (namespace) (up{container="oauth2-proxy"})`, the `up`
|
||||||
|
# SERIES EXISTS, so `count by (namespace)` returns 1, `unless` removes the namespace, and the
|
||||||
|
# alert GOES QUIET while nothing is being scraped at all — a green signal standing in for an
|
||||||
|
# absent one, which is the precise disease this whole rule was written to cure. Deleting the
|
||||||
|
# `== 1` makes THIS test fail and every other test in this file still pass, which is the
|
||||||
|
# property that makes it worth having.
|
||||||
|
- interval: 1m
|
||||||
|
name: a discovered-but-FAILING target does not count as observed (up==0 must still fire)
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="jackett",pod="jackett-6877fcd9f5-ht9d9",service="kube-prometheus-stack-kube-state-metrics",uid="5b57d48f-e7d3-4050-9cd1-16aaaa052996"}'
|
||||||
|
values: '1x130'
|
||||||
|
# The PodMonitor target exists and never succeeds: NetworkPolicy drops the packets, so the
|
||||||
|
# scrape times out and `up` is published as 0 rather than going absent.
|
||||||
|
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.0.189:44180",job="oauth2-proxy-sidecars",namespace="jackett",pod="jackett-6877fcd9f5-ht9d9"}'
|
||||||
|
values: '0x130'
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett"}'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples:
|
||||||
|
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="jackett",severity="warning"}'
|
||||||
|
value: 1
|
||||||
|
# And the inner clause itself yields NOTHING while the scrape is failing — which is the
|
||||||
|
# mechanism the alert depends on, asserted directly so a future reader can see why the
|
||||||
|
# `== 1` matters without re-deriving it. (Scenario 4 below covers the other direction:
|
||||||
|
# a succeeding scrape really does clear the alert, so `== 1` has not made the rule
|
||||||
|
# impossible to satisfy.)
|
||||||
|
- expr: 'count by (namespace) (up{container="oauth2-proxy"} == 1)'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples: []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 4. A FLAPPING target: up goes 1 -> 0 and stays there. The alert must come BACK, not latch
|
||||||
|
# cleared. Covers the ordering bug where a once-observed namespace is permanently excluded.
|
||||||
|
- interval: 1m
|
||||||
|
name: a target that starts working and then fails re-fires
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_pod_container_info{container="oauth2-proxy",container_id="containerd://937ae6fc",endpoint="http",image="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",image_id="quay.io/oauth2-proxy/oauth2-proxy@sha256:d62e2d81",image_spec="quay.io/oauth2-proxy/oauth2-proxy:v7.8.1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="radarr",pod="radarr-7d55cbffb9-42hmh",service="kube-prometheus-stack-kube-state-metrics",uid="3333cccc-0000-0000-0000-000000000001"}'
|
||||||
|
values: '1x250'
|
||||||
|
# Healthy for the first 60m, then every scrape fails for the rest.
|
||||||
|
- series: 'up{container="oauth2-proxy",endpoint="metrics",instance="10.0.1.77:44180",job="oauth2-proxy-sidecars",namespace="radarr",pod="radarr-7d55cbffb9-42hmh"}'
|
||||||
|
values: '1x60 0x180'
|
||||||
|
promql_expr_test:
|
||||||
|
# Quiet while it was working.
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",namespace="radarr"}'
|
||||||
|
eval_time: 55m
|
||||||
|
exp_samples: []
|
||||||
|
# Firing again an hour after it broke.
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="radarr"}'
|
||||||
|
eval_time: 125m
|
||||||
|
exp_samples:
|
||||||
|
- labels: 'ALERTS{alertname="OAuth2ProxyGateUnobserved",alertstate="firing",namespace="radarr",severity="warning"}'
|
||||||
|
value: 1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 5. NO GATE, NO ALERT. A namespace with no oauth2-proxy container must never appear, or the
|
||||||
|
# rule would page for every app on the platform the day someone widened the matcher.
|
||||||
|
- interval: 1m
|
||||||
|
name: namespaces without a gate sidecar are never reported
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_pod_container_info{container="plex",container_id="containerd://feed0001",endpoint="http",image="ghcr.io/example/plex:1",image_id="ghcr.io/example/plex@sha256:feed0001",image_spec="ghcr.io/example/plex:1",instance="10.0.1.213:8080",job="kube-state-metrics",namespace="plex",pod="plex-0",service="kube-prometheus-stack-kube-state-metrics",uid="2222bbbb-0000-0000-0000-000000000001"}'
|
||||||
|
values: '1x130'
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: 'ALERTS{alertname="OAuth2ProxyGateUnobserved"}'
|
||||||
|
eval_time: 65m
|
||||||
|
exp_samples: []
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
# oauth2-proxy AUTH-VISIBILITY alerting (R-OBS-04).
|
||||||
|
#
|
||||||
|
# ============================================================================================
|
||||||
|
# WHY THIS FILE EXISTS — THE 2026-09-30 JACKETT FAILURE THAT NOTHING OBSERVED
|
||||||
|
# ============================================================================================
|
||||||
|
# Jackett's UI stopped working. Reloading never fixed it and the user was never re-prompted to
|
||||||
|
# log in. The sidecar's own access log had the whole story:
|
||||||
|
#
|
||||||
|
# [oauthproxy.go:1026] No valid authentication in request. Initiating login.
|
||||||
|
# GET "/api/v2.0/indexers/yts/config?_=..." HTTP/1.1 ... 302 400
|
||||||
|
# GET "/api/v2.0/indexers/thepiratebay/config?_=..." HTTP/1.1 ... 302 409
|
||||||
|
# ... ten of these at 15:30:53, then a fresh [AuthSuccess] at 15:31:06,
|
||||||
|
# then "No valid authentication in request" again 26 SECONDS LATER, inside the same session.
|
||||||
|
#
|
||||||
|
# NOTHING ANYWHERE ELSE RECORDED IT. oauth2-proxy answers an unauthenticated XHR with a 302 to
|
||||||
|
# Keycloak rather than a 401; the browser follows it silently, the live SSO session satisfies it
|
||||||
|
# without a prompt, and the XHR resolves 200 carrying a 44 KB HTML dashboard where the app's
|
||||||
|
# JavaScript wanted JSON. From the browser, from the user, and from any uptime check, the service
|
||||||
|
# is healthy. `jackett-down` (apps/wave-0/jackett.yaml) watches
|
||||||
|
# kube_deployment_status_replicas_available, which was 1 throughout and would have been 1 if the
|
||||||
|
# gate had rejected every request ever made.
|
||||||
|
#
|
||||||
|
# ============================================================================================
|
||||||
|
# WHY THIS FILE DOES NOT CONTAIN THE OBVIOUS ALERT — MEASURED, prod, 2026-09-30
|
||||||
|
# ============================================================================================
|
||||||
|
# The alert you would want is "a gated app is serving a sustained rate of auth redirects to a
|
||||||
|
# user who is already authenticated". It is NOT WRITABLE against this cluster's TSDB, and the
|
||||||
|
# reason is this file's real subject:
|
||||||
|
#
|
||||||
|
# THE PER-APP GATE SIDECARS EXPORT NO METRICS AT ALL.
|
||||||
|
#
|
||||||
|
# $ promtool query instant :9090 'count by (__name__) ({__name__=~"oauth2_proxy.*"})'
|
||||||
|
# oauth2_proxy_requests_in_flight => 2 <- the SHARED proxy's two replicas. That is ALL.
|
||||||
|
#
|
||||||
|
# PARTLY SUPERSEDED 2026-10-01, and the correction matters more than the original. PR #28 gave
|
||||||
|
# the five FLEET sidecars `--metrics-address=0.0.0.0:44180` and a named `metrics` port, and the
|
||||||
|
# endpoint genuinely serves (verified by payload: http=200, 10208 bytes, all three
|
||||||
|
# `oauth2_proxy_*` families present — not merely by the flag being in `args`). podmonitor.yaml
|
||||||
|
# in this directory now discovers them. But the scrape is still DROPPED by the app namespaces'
|
||||||
|
# default-deny ingress, so the TSDB remains empty for those namespaces; and crafty still has no
|
||||||
|
# metrics listener at all. The rule below is unchanged in purpose and still has work to do.
|
||||||
|
#
|
||||||
|
# Every sidecar (crafty, jackett, pihole, qbittorrent, radarr, sonarr) ran with
|
||||||
|
# `--http-address=0.0.0.0:4180` and NO `--metrics-address`, so oauth2-proxy's metrics listener
|
||||||
|
# was never started; the container declared only port `http:4180`, there was no `metrics` port on
|
||||||
|
# any of their Services, and there was no ServiceMonitor or PodMonitor for any of them anywhere
|
||||||
|
# in the cluster (measured: `kubectl get servicemonitors,podmonitors -A`). Six gates, zero
|
||||||
|
# telemetry. An alert written against `oauth2_proxy_requests_total{namespace="jackett"}` would
|
||||||
|
# have been PERMANENTLY INERT — present, evaluating, silent, and indistinguishable from health.
|
||||||
|
# This repo has shipped that mistake before (VeleroNoRecentBackup, the Longhorn rules), which is
|
||||||
|
# why `conformance/tests/l0-static/alert-metrics-have-scrape-targets` exists, and why the
|
||||||
|
# temptation was refused here rather than ratcheted into a register.
|
||||||
|
#
|
||||||
|
# The SHARED proxy IS scraped (job="oauth2-proxy", 2 targets, up=1) but it is not in the data
|
||||||
|
# path for any sidecar-gated app — each app gates itself — and its current pods export no
|
||||||
|
# `oauth2_proxy_requests_total` at all, having proxied nothing since they started. An alert on
|
||||||
|
# the shared instance would not have fired on this incident either. Saying so is the point.
|
||||||
|
#
|
||||||
|
# So this file alerts on the CONDITION THAT MADE THE INCIDENT INVISIBLE, not on the incident.
|
||||||
|
# That condition is measurable today, from two already-scraped metric families, and it is the
|
||||||
|
# precondition for ever being able to alert on the incident itself. It self-clears the moment a
|
||||||
|
# sidecar gains a metrics endpoint and a scrape — at which point the auth-churn rule becomes
|
||||||
|
# writable and belongs here beside it. See runbooks/oauth2-proxy-auth-failures.md, which is the
|
||||||
|
# by-hand equivalent an operator uses in the meantime.
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PrometheusRule
|
||||||
|
metadata:
|
||||||
|
name: oauth2-proxy-auth-visibility
|
||||||
|
namespace: oauth2-proxy
|
||||||
|
labels:
|
||||||
|
release: kube-prometheus-stack # kube-prometheus-stack's ruleSelector
|
||||||
|
app.kubernetes.io/name: oauth2-proxy
|
||||||
|
spec:
|
||||||
|
groups:
|
||||||
|
- name: oauth2-proxy.auth-visibility
|
||||||
|
rules:
|
||||||
|
- alert: OAuth2ProxyGateUnobserved
|
||||||
|
# A namespace runs an oauth2-proxy gate container, and Prometheus holds no scrape
|
||||||
|
# target for that container in that namespace.
|
||||||
|
#
|
||||||
|
# BOTH SIDES ARE ALREADY-SCRAPED FAMILIES, deliberately:
|
||||||
|
# * kube_pod_container_info — kube-state-metrics, the declared owner in
|
||||||
|
# alert-metrics-have-scrape-targets. It sees the container whether or not the
|
||||||
|
# container exports anything, which is exactly the property needed here.
|
||||||
|
# * up — UNIVERSAL. `up` carries the `container` label for any
|
||||||
|
# ServiceMonitor/PodMonitor target, so this works for a future per-app scrape
|
||||||
|
# without knowing what job name it will pick.
|
||||||
|
# Nothing in this expression touches `oauth2_proxy_*`, so the rule cannot itself be
|
||||||
|
# inert for the reason it is reporting.
|
||||||
|
#
|
||||||
|
# MEASURED ON PROD 2026-09-30 — this expression returned six namespaces:
|
||||||
|
# {namespace="crafty"} {namespace="jackett"} {namespace="pihole"}
|
||||||
|
# {namespace="qbittorrent"} {namespace="radarr"} {namespace="sonarr"}
|
||||||
|
# and excluded namespace="oauth2-proxy", whose two replicas ARE scraped. It fires on
|
||||||
|
# the state the cluster is in; it is not a rule waiting for a hypothetical.
|
||||||
|
#
|
||||||
|
# `== 1` IS LOAD-BEARING, AND IT IS A BUG FIX (2026-10-01). This clause was originally
|
||||||
|
# a bare `up{container="oauth2-proxy"}`, which was WRONG in the one way that matters
|
||||||
|
# here: a target that is DISCOVERED BUT FAILING still publishes `up`, with value 0. So
|
||||||
|
# the series exists, `count by (namespace)` returns 1, `unless` removes the namespace,
|
||||||
|
# and the alert GOES QUIET while nothing whatsoever is being scraped. That is this
|
||||||
|
# branch's own disease — a green signal standing in for an absent one — reproduced
|
||||||
|
# inside the alert written to cure it. It was not hypothetical: podmonitor.yaml lands
|
||||||
|
# five targets whose scrape is DROPPED by NetworkPolicy (measured; see that file), so
|
||||||
|
# the bare form would have silently cleared on the very next commit. `== 1` means
|
||||||
|
# "observed" requires a scrape that actually succeeds.
|
||||||
|
#
|
||||||
|
# A failing target is therefore reported by BOTH this rule and kube-prometheus-stack's
|
||||||
|
# generic TargetDown. That duplication is deliberate: TargetDown says "a target is
|
||||||
|
# down", this says "an authentication gate is unobserved", and during an incident those
|
||||||
|
# are different sentences. The overlap is the cheap half of the trade.
|
||||||
|
expr: |
|
||||||
|
count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
|
||||||
|
unless
|
||||||
|
count by (namespace) (up{container="oauth2-proxy"} == 1)
|
||||||
|
# 1h, not 5m: a rollout briefly has a running sidecar whose target has not been
|
||||||
|
# discovered yet, and this condition is a standing architectural gap, never an
|
||||||
|
# emergency. Long enough that no deploy can produce it.
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "oauth2-proxy gate in {{ $labels.namespace }} exports no metrics"
|
||||||
|
description: >-
|
||||||
|
The {{ $labels.namespace }} namespace runs an oauth2-proxy gate container and
|
||||||
|
Prometheus has NO scrape target for it, so every authentication decision that gate
|
||||||
|
makes — every redirect to Keycloak, every 403 on a group check, every session that
|
||||||
|
silently fails to hold — is invisible to metrics and to alerting. This is not a
|
||||||
|
theoretical gap: on 2026-09-30 the jackett gate answered a burst of the app's own
|
||||||
|
XHR calls with 302s to Keycloak, re-established a session, and lost it again 26
|
||||||
|
seconds later, and the only record anywhere was a line in the sidecar's stdout.
|
||||||
|
No alert could have fired, because there is nothing to alert on. Until a scrape
|
||||||
|
exists the runbook's LogQL queries are the substitute; the fix is
|
||||||
|
`--metrics-address=0.0.0.0:44180` on the sidecar plus a `metrics` port and a
|
||||||
|
ServiceMonitor, after which the auth-churn rule becomes writable.
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/oauth2-proxy-auth-failures"
|
||||||
@@ -14,3 +14,12 @@ resources:
|
|||||||
- oauth2-proxy-credentials.sops.yaml
|
- oauth2-proxy-credentials.sops.yaml
|
||||||
- httproute.yaml # forward-auth sign-in/callback ingress (was referenced in-place by platform-config)
|
- httproute.yaml # forward-auth sign-in/callback ingress (was referenced in-place by platform-config)
|
||||||
- servicemonitor.yaml # Prometheus scrape (was referenced in-place by platform-config)
|
- servicemonitor.yaml # Prometheus scrape (was referenced in-place by platform-config)
|
||||||
|
# Alerting on whether the gates are OBSERVABLE at all. Travels with the servicemonitor above
|
||||||
|
# for the same reason it does: monitoring.coreos.com CRDs are registered by the `platform`
|
||||||
|
# tier, which is Ready before this tier's platform-config dependency completes.
|
||||||
|
- auth-visibility-alerts.yaml
|
||||||
|
# Discovers the PER-APP gate sidecars' :44180 metrics endpoints by pod label (PR #28 added the
|
||||||
|
# listener; no Service exposes it, so this is a PodMonitor, not a ServiceMonitor). The scrape is
|
||||||
|
# still denied by the app namespaces' default-deny ingress — read podmonitor.yaml's header
|
||||||
|
# before concluding the targets should be up.
|
||||||
|
- podmonitor.yaml
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
# PodMonitor for the PER-APP oauth2-proxy gate sidecars (R-OBS-04).
|
||||||
|
#
|
||||||
|
# ============================================================================================
|
||||||
|
# WHY A PodMonitor AND NOT A ServiceMonitor
|
||||||
|
# ============================================================================================
|
||||||
|
# PR #28 gave all five fleet sidecars `--metrics-address=0.0.0.0:44180` and a named `metrics`
|
||||||
|
# container port. It did NOT add a Service port, and measured on prod 2026-10-01 none of the five
|
||||||
|
# Services exposes 44180:
|
||||||
|
#
|
||||||
|
# jackett jackett: [('port-9117', 9117), ('authproxy', 4180)]
|
||||||
|
# radarr radarr: [('port-7878', 7878), ('authproxy', 4180)]
|
||||||
|
# sonarr sonarr: [('port-8989', 8989), ('authproxy', 4180)]
|
||||||
|
# qbittorrent qbittorrent: [('port-8080', 8080), ('authproxy', 4180)]
|
||||||
|
# pihole pihole: [('port-80', 80), ('authproxy', 4180)]
|
||||||
|
#
|
||||||
|
# A ServiceMonitor selects Service PORTS, so it would have nothing to select. A PodMonitor targets
|
||||||
|
# pod ports directly and bypasses Services entirely — so this needs no change to the Services, and
|
||||||
|
# therefore no change to `catalog/render.py` or the `apps/wave-*` renders and goldens.
|
||||||
|
#
|
||||||
|
# SELECTOR: the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL, not a namespace list.
|
||||||
|
# Measured 2026-10-01: that label is present on exactly the five fleet gate pods (jackett, radarr,
|
||||||
|
# sonarr, qbittorrent, pihole) and absent from crafty's, which is a separately-managed deployment
|
||||||
|
# (platform/crafty) that still has no `--metrics-address` at all. So the selector describes the
|
||||||
|
# PROPERTY being scraped rather than enumerating today's apps, and a sixth forward-auth app is
|
||||||
|
# picked up with no edit here. `namespaceSelector.any: true` because those five namespaces are
|
||||||
|
# created by the apps-wave-0/1 tiers, which this tier does not and must not depend on.
|
||||||
|
#
|
||||||
|
# ============================================================================================
|
||||||
|
# STATUS: THE SCRAPE WORKS. Verified on prod 2026-10-05 (PR #49 merged as 21c88b0).
|
||||||
|
# ============================================================================================
|
||||||
|
# All 6 targets in pool podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0 read `up == 1`,
|
||||||
|
# health=up, lastError='', lastScrapeDuration 0.001-0.006s. The TSDB holds oauth2_proxy_* for
|
||||||
|
# these namespaces for the first time: 5 metric names / 148 series, with live counters
|
||||||
|
# (oauth2_proxy_requests_total 35072 qbittorrent, 23560 radarr, 1284 sonarr). All 5 TargetDown
|
||||||
|
# and 5 of 6 OAuth2ProxyGateUnobserved resolved; only namespace="crafty" still fires, and that
|
||||||
|
# is correct — see CRAFTY below.
|
||||||
|
#
|
||||||
|
# The missing piece was an ingress allowance, and it now ships WITH EACH APP, rendered by
|
||||||
|
# catalog/render.py's r_monitoring_ingress_policy as `<app>-allow-monitoring-scrape`:
|
||||||
|
# ns monitoring -> the app's endpointSelector -> TCP 44180 only. See WHY NOT IN THIS DIRECTORY
|
||||||
|
# below for why it is rendered per-app instead of shipped from this tier.
|
||||||
|
#
|
||||||
|
# Keep the history below. It is why the fix is shaped the way it is, and `up == 1` in
|
||||||
|
# auth-visibility-alerts.yaml is load-bearing precisely because of it.
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# HISTORY: 2026-10-02 -> 2026-10-05, THE SCRAPE WAS DENIED BY NETWORKPOLICY FOR 63.7h
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# This PodMonitor was NECESSARY AND NOT SUFFICIENT: the endpoint served, and the path from ns
|
||||||
|
# monitoring was blocked. Both halves measured on prod 2026-10-01, read-only:
|
||||||
|
#
|
||||||
|
# THE ENDPOINT IS HEALTHY — port-forward straight to the jackett pod's 44180:
|
||||||
|
# GET /metrics -> http=200 bytes=10208
|
||||||
|
# # TYPE oauth2_proxy_requests_in_flight gauge
|
||||||
|
# # TYPE oauth2_proxy_requests_total counter
|
||||||
|
# # TYPE oauth2_proxy_response_duration_seconds histogram
|
||||||
|
# oauth2_proxy_requests_total{code="200"} 3
|
||||||
|
# oauth2_proxy_requests_total{code="302"} 2
|
||||||
|
#
|
||||||
|
# THE SCRAPE PATH IS DROPPED — from a pod in ns monitoring (grafana, which sits beside
|
||||||
|
# Prometheus and so occupies the same policy position) to the jackett pod IP:
|
||||||
|
# http://10.0.0.189:44180/metrics -> wget: download timed out
|
||||||
|
# http://10.0.0.189:4180/ping -> wget: download timed out (even the "allowed" port)
|
||||||
|
# http://<shared-proxy>:44180/metrics -> 200, oauth2_proxy_* present (control: ns oauth2-proxy
|
||||||
|
# has NO NetworkPolicy, which is the only reason the
|
||||||
|
# shared instance has ever been scrapable)
|
||||||
|
#
|
||||||
|
# WHY: ns jackett's `default-deny` NetworkPolicy is `podSelector: {}` with
|
||||||
|
# `policyTypes: [Ingress, Egress]` and an `egress` rule ONLY — no `ingress` key, which in
|
||||||
|
# NetworkPolicy semantics is deny-all-ingress. The only ingress allowance is the CNP
|
||||||
|
# `jackett-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180 — the Gateway's Envoy
|
||||||
|
# and nothing else. Prometheus matches no clause and its packets are dropped, not refused.
|
||||||
|
#
|
||||||
|
# THE TARGETS READ `up == 0` FROM 2026-10-02 05:09 UTC UNTIL THE ALLOWANCE LANDED — one
|
||||||
|
# continuous 63.7h episode (a 15d query_range shows 1 episode per namespace, not flapping),
|
||||||
|
# starting 11 minutes after this file was created, i.e. TargetDown's `for: 10m`. It was never
|
||||||
|
# silenced, and that was the right call: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
|
||||||
|
# requires `up == 1`, precisely so a discovered-but-failing target does not read as "observed"
|
||||||
|
# (see that file's header — this exact trap is why the `== 1` is there).
|
||||||
|
#
|
||||||
|
# The signature to recognise if it ever returns: lastScrapeDuration pinned at EXACTLY the
|
||||||
|
# scrapeTimeout (10.0s) with `context deadline exceeded`. That is a DROP, not a refusal — a
|
||||||
|
# refused connection fails in milliseconds. Post-fix the same targets scrape in 1-6ms.
|
||||||
|
#
|
||||||
|
# THIS IS THE SAME BUG LONGHORN HAD, and the platform has a documented fix idiom for it:
|
||||||
|
# platform/longhorn/networkpolicy-metrics.yaml, whose header records the identical fingerprint
|
||||||
|
# measured on 2026-09-13 — ServiceMonitor in place, 3 targets DISCOVERED, `up` 0/0/0, TSDB empty,
|
||||||
|
# `lastError: context deadline exceeded` (a drop, not a refusal). The fix there is a narrow
|
||||||
|
# additive `networking.k8s.io/v1` NetworkPolicy: ns monitoring -> the pod label, that one port.
|
||||||
|
#
|
||||||
|
# WHY NOT IN THIS DIRECTORY (the reasoning the fix followed). It would have to be five objects, one per app
|
||||||
|
# namespace, and those namespaces are created by apps-wave-0/1. Measured Flux graph, 2026-10-01:
|
||||||
|
# oauth2-proxy dependsOn=['platform-config'] <- this tier
|
||||||
|
# apps-wave-0 dependsOn=['platform-config'] <- SIBLING, no ordering between them
|
||||||
|
# apps-wave-1 dependsOn=['apps-wave-0']
|
||||||
|
# They are siblings, so on a cold bootstrap there is no guarantee ns jackett exists when this tier
|
||||||
|
# applies, and a NetworkPolicy naming a missing namespace fails to apply. It would work on today's
|
||||||
|
# running cluster and break on a rebuild — the "works now, broken on restore" shape this repo
|
||||||
|
# keeps paying for. Longhorn's precedent is safe only because longhorn-system is created by the
|
||||||
|
# same tier that ships its policy. The allowance therefore belongs WITH THE APPS, rendered next to
|
||||||
|
# the rest of each app's network policy — i.e. in the catalog/renderer. THAT IS WHERE IT LANDED:
|
||||||
|
# `r_monitoring_ingress_policy`, keyed on `_needs_oauth2_sidecar`, one narrow rule per app.
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# CRAFTY: STILL UNOBSERVED, AND THIS FILE CANNOT HELP IT
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# `OAuth2ProxyGateUnobserved{namespace="crafty"}` still fires after the fix, correctly. crafty is
|
||||||
|
# a hand-maintained Deployment under platform/crafty, OUTSIDE the renderer, and its oauth2-proxy
|
||||||
|
# container has NO `--metrics-address` at all (measured 2026-10-05) — so its metrics listener
|
||||||
|
# never starts and there is no port to open. The selector below therefore does not match it, and
|
||||||
|
# the per-app allowance is never rendered for it. Closing crafty's gap needs the flag plus a
|
||||||
|
# `metrics` containerPort added to its own manifest FIRST; the allowance is the second half, and
|
||||||
|
# it would have to be written by hand there rather than rendered. Open follow-up work.
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PodMonitor
|
||||||
|
metadata:
|
||||||
|
name: oauth2-proxy-sidecars
|
||||||
|
namespace: oauth2-proxy
|
||||||
|
labels:
|
||||||
|
release: kube-prometheus-stack # kube-prometheus-stack's podMonitorSelector
|
||||||
|
app.kubernetes.io/name: oauth2-proxy
|
||||||
|
homelab.keithjackson.dev/purpose: metrics-scrape
|
||||||
|
spec:
|
||||||
|
namespaceSelector:
|
||||||
|
# The gate sidecars live in the app namespaces, not this one. `any` rather than a fixed list so
|
||||||
|
# a new forward-auth app needs no edit here — the pod label below is what scopes this.
|
||||||
|
any: true
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
# The PROPERTY, not the app list. Present on exactly the five fleet gate pods (measured
|
||||||
|
# 2026-10-01); absent from crafty, which has no metrics listener yet.
|
||||||
|
homelab.keithjackson.dev/auth-mode: forward-auth
|
||||||
|
podMetricsEndpoints:
|
||||||
|
# By PORT NAME (`metrics`, 44180), never by number: the name is what PR #28 declared on the
|
||||||
|
# container, and a numeric port here would silently stop matching if the port were renumbered.
|
||||||
|
- port: metrics
|
||||||
|
path: /metrics
|
||||||
|
interval: 30s
|
||||||
|
# 10s, matching the drop signature above: a DENIED path does not refuse, it hangs until the
|
||||||
|
# scrape times out. Leaving the default would make each failing target hold a scrape slot for
|
||||||
|
# the full interval.
|
||||||
|
scrapeTimeout: 10s
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# platform/vpn-ingress — WireGuard remote access (north-south INGRESS)
|
||||||
|
|
||||||
|
Lets the operator's own devices — Android, Linux, Windows, macOS, iOS — dial in from anywhere on
|
||||||
|
the internet and behave as though plugged into the home LAN: full reach to `192.168.2.0/24` and
|
||||||
|
egress out the house WAN.
|
||||||
|
|
||||||
|
> **This is not `platform/vpn-egress`.** They are opposite directions and share nothing but the
|
||||||
|
> word "VPN". `vpn-egress` pushes *pods out* to the internet through a PIA tunnel (ADR-0015,
|
||||||
|
> R-NET-05/06/07). This component lets *remote clients in*. Changing one never affects the other.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Transport | WireGuard, **udp/51820**, kernel implementation |
|
||||||
|
| Where it runs | `hostNetwork` **DaemonSet** — one warm listener on every node |
|
||||||
|
| Client subnet | `${WG_CLIENT_CIDR}` = `10.90.0.0/24`; `.1` is the server, `.2`–`.254` devices |
|
||||||
|
| Authorisation | a device's **public key** is in [`peers.yaml`](./peers.yaml), or it cannot connect |
|
||||||
|
| The one secret | the server's private key, Secret `wg-server-key`, **created out of band** |
|
||||||
|
| Exposure | one hand-made WAN port-forward on the SOHO router, off-repo |
|
||||||
|
| Enrolling a device | [`infra/vpn-ingress/new-peer.sh`](../../infra/vpn-ingress/new-peer.sh) |
|
||||||
|
| Procedure / bring-up | [`runbooks/vpn-remote-access.md`](../../runbooks/vpn-remote-access.md) |
|
||||||
|
|
||||||
|
## Why WireGuard
|
||||||
|
|
||||||
|
The requirement names four client platforms. WireGuard is the only option that has a *first-party*
|
||||||
|
client on all of them (official apps for Android, iOS, Windows and macOS; in-kernel on Linux since
|
||||||
|
5.6, so a Linux client needs no userspace daemon at all). It is also the smallest thing to expose:
|
||||||
|
one UDP port, no cipher negotiation, no PKI, no user database, no TLS stack.
|
||||||
|
|
||||||
|
OpenVPN would have needed a CA, per-device certificates and a revocation list — three pieces of
|
||||||
|
long-lived state this repo would then have to protect and rotate. IKEv2/IPsec has native clients but
|
||||||
|
is painful to run in a container and brittle behind double NAT. Neither buys anything here.
|
||||||
|
|
||||||
|
### Why this is safe to expose to the whole internet
|
||||||
|
|
||||||
|
The honest answer is not "because it is encrypted" — it is the handshake's failure mode:
|
||||||
|
|
||||||
|
**An unauthenticated packet gets no reply at all.** WireGuard's first handshake message carries a
|
||||||
|
MAC keyed with the *responder's own public key*. If the sender does not already know it, the packet
|
||||||
|
is dropped silently — no error, no reset, no banner, nothing a scanner can fingerprint. From the
|
||||||
|
outside, udp/51820 is indistinguishable from a closed port. There is no login prompt to brute-force,
|
||||||
|
no version string to match against a CVE, and no amplification to abuse.
|
||||||
|
|
||||||
|
That is the whole security argument, and it is why the exposed port carries no IP allowlist (which
|
||||||
|
would also have made roaming clients unusable — a phone's address changes with every cell tower).
|
||||||
|
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN: a
|
||||||
|
resolver answers anybody, so it must be scoped by address. WireGuard answers nobody.
|
||||||
|
|
||||||
|
What it does **not** protect against is a stolen device — anyone holding a device's private key
|
||||||
|
*is* that device. That is what revocation is for: delete the stanza, merge, done (~1 min).
|
||||||
|
|
||||||
|
## Architecture, and the one decision that drives everything
|
||||||
|
|
||||||
|
```
|
||||||
|
phone on 5G SOHO router k3s-prod-N (host netns)
|
||||||
|
┌──────────────┐ udp/51820 ┌───────────────┐ forward ┌────────────────────────────┐
|
||||||
|
│ WireGuard app│─────────────▶│ WAN 51820 ──▶ │───────────▶│ wg0 10.90.0.1/24 │
|
||||||
|
│ 10.90.0.2/32 │ over the │ node IP:51820 │ │ decrypt, then FORWARD │
|
||||||
|
└──────────────┘ internet └───────────────┘ │ SNAT to the node's addr │
|
||||||
|
└─────────────┬──────────────┘
|
||||||
|
│ as `host`/`remote-node`
|
||||||
|
┌─────────────────────────────────┼──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
LAN 192.168.2.0/24 Gateway VIP .20 (16 web UIs) WAN / internet
|
||||||
|
(NAS, router, printers) PiHole DNS VIP .23
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hostNetwork: true` is required, not convenient
|
||||||
|
|
||||||
|
This is the only genuinely interesting decision in the component, and it was forced by a *measured*
|
||||||
|
property of this cluster rather than chosen for convenience.
|
||||||
|
|
||||||
|
In Cilium, what a packet may reach is decided by the **security identity of its source**. A
|
||||||
|
WireGuard server running as an ordinary pod must SNAT client traffic to its own pod IP (Cilium drops
|
||||||
|
a packet whose source is not the endpoint's own address), so every remote client would have arrived
|
||||||
|
everywhere wearing *that pod's* identity. On 2026-10-04 this cluster measured what that costs
|
||||||
|
(`decisions/2026-10-04-plex-advertise-plexdirect.md`): a request to the shared Gateway VIP **from
|
||||||
|
inside a pod returns `403 Access denied` from Envoy for all 16 Gateway hostnames**, because Cilium's
|
||||||
|
Gateway preserves the original source identity and enforces the *destination's* ingress policy —
|
||||||
|
and every app admits `reserved:ingress` plus named peers, never an arbitrary pod. LAN browsers work
|
||||||
|
only because they arrive as `reserved:world` / `reserved:host`.
|
||||||
|
|
||||||
|
So the pod-networked version of this component would have produced a tunnel that came up healthy,
|
||||||
|
reported connected peers, routed packets — and 403'd **every single cluster web UI**: plex, gitea,
|
||||||
|
keycloak, nextcloud, grafana, headlamp, the apex. `*.home.keithjackson.dev` would not even have
|
||||||
|
resolved, because `apps/wave-0/pihole-dns.yaml`'s ingress policy deliberately omits the `cluster`
|
||||||
|
entity. A VPN that looks up and is useless is the worst available outcome, and nothing in the
|
||||||
|
manifests would have hinted at it.
|
||||||
|
|
||||||
|
In the **host** netns there is no pod identity to carry. Forwarded client traffic is SNATed to the
|
||||||
|
node's own address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||||
|
byte-for-byte what a LAN client looks like. Three further problems vanish with it:
|
||||||
|
|
||||||
|
* `net.ipv4.ip_forward` is **already 1** on these nodes (`node_baseline` writes
|
||||||
|
`/etc/sysctl.d/99-k3s-cilium.conf`). It is *not* in the kubelet's safe-sysctl allowlist, so a
|
||||||
|
pod-netns build would have needed `--allowed-unsafe-sysctls` on all three kubelets — a
|
||||||
|
node-restart change to the k3s service, to run a VPN.
|
||||||
|
* **No LoadBalancer VIP**, so no `CiliumL2AnnouncementPolicy` lease lottery (the backend-blind
|
||||||
|
announcer that cost this LAN an 8-day Minecraft outage — see
|
||||||
|
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node`) and no
|
||||||
|
`externalTrafficPolicy` trade-off to make at all.
|
||||||
|
* **No double encapsulation.** Client UDP lands on the node's own NIC, so the underlay is a flat
|
||||||
|
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` simply correct.
|
||||||
|
|
||||||
|
`hostNetwork` is *permitted* here, and not by oversight:
|
||||||
|
`platform/kyverno/policies/restrict-host-namespaces.yaml` matches `namespaceSelector:
|
||||||
|
app-namespace=true` only, and names this exact carve-out in its own comment — "platform components
|
||||||
|
that do … e.g. Cilium, node-exporter … live in platform namespaces that do NOT carry the
|
||||||
|
app-namespace label". [`namespace.yaml`](./namespace.yaml) is deliberately unlabelled.
|
||||||
|
|
||||||
|
**The cost, stated rather than buried:** a `hostNetwork` pod has no Cilium endpoint, so **no
|
||||||
|
NetworkPolicy constrains this workload** — not [`networkpolicy.yaml`](./networkpolicy.yaml), not
|
||||||
|
anything. That file says so at length instead of implying a protection it cannot provide. The
|
||||||
|
boundary around this port is WireGuard's peer authentication, full stop.
|
||||||
|
|
||||||
|
### Why a DaemonSet
|
||||||
|
|
||||||
|
The WAN forward names **one node address**, by hand, off-repo (same shape as the plex tcp/32400
|
||||||
|
forward). A single-replica Deployment would float on every reschedule and silently invalidate it.
|
||||||
|
A DaemonSet instead puts an identical listener on all three nodes — every node answers the same
|
||||||
|
keys — so **losing the forwarded node is a one-line router change**, with no cluster work and no
|
||||||
|
re-issued client profiles. The two idle listeners cost ~15 MiB each.
|
||||||
|
|
||||||
|
A pinned `LoadBalancer` VIP was the considered alternative (it would make the forward immune to node
|
||||||
|
replacement). Rejected: it reintroduces the L2-announcement lease, SNAT, and an extra node-to-node
|
||||||
|
hop into the one path whose failure mode is *"I am away from home and cannot get in"*. Fewer
|
||||||
|
translations win here.
|
||||||
|
|
||||||
|
### The split that keeps authorisation reviewable
|
||||||
|
|
||||||
|
| half | where | why |
|
||||||
|
|---|---|---|
|
||||||
|
| peer **public** keys + addresses | [`peers.yaml`](./peers.yaml), **plaintext in git** | a public key grants nothing by itself, and this makes *"who can get into my house"* a reviewable PR diff with `git log` as its audit trail |
|
||||||
|
| the server's **private** key | Secret `wg-server-key`, **out of band** | the only real secret; an agent must not mint a long-lived private key (CLAUDE.md §6) |
|
||||||
|
|
||||||
|
The two are joined only at pod start, by the init container, into a **`medium: Memory` emptyDir** —
|
||||||
|
so the assembled config holding the private key never touches any node's disk and dies with the pod.
|
||||||
|
|
||||||
|
Encrypting the peer register would have hidden exactly the change a reviewer most needs to see: a
|
||||||
|
new line granting a new device access to the whole LAN.
|
||||||
|
|
||||||
|
## What a client can reach — and how to narrow it
|
||||||
|
|
||||||
|
By design, **everything a LAN machine can reach**: all of `192.168.2.0/24`, the shared Gateway's 16
|
||||||
|
hostnames, PiHole, the NAS, the router's admin UI, and the internet via the house WAN.
|
||||||
|
|
||||||
|
**That includes the three k3s nodes themselves** — `192.168.2.10/.11/.12`, and therefore the
|
||||||
|
kube-apiserver on 6443, kubelet on 10250 and SSH on 22. An enrolled device is as privileged as a
|
||||||
|
machine sitting in the house, which is what "reach the entire local network" asks for; it is
|
||||||
|
recorded here because it is the largest consequence of saying yes to that, and because nobody should
|
||||||
|
discover it later. **It is also why a lost device must be revoked promptly** (delete its stanza,
|
||||||
|
merge; ~1 min to take effect).
|
||||||
|
|
||||||
|
To grant less, narrow it at the **client** profile first — `new-peer.sh --split` emits a
|
||||||
|
split-tunnel profile routing only the LAN and the tunnel subnet, so the device keeps its own
|
||||||
|
internet path. To enforce less *server-side*, add filtering to the `PostUp` chain in
|
||||||
|
[`daemonset.yaml`](./daemonset.yaml) (e.g. `iptables -A FORWARD -i wg0 -d 192.168.2.10 -j DROP`
|
||||||
|
before the accept rules) — the FORWARD chain there is the only enforcement point, since no
|
||||||
|
NetworkPolicy applies.
|
||||||
|
|
||||||
|
## Operating it
|
||||||
|
|
||||||
|
* **Enrol a device:** `./infra/vpn-ingress/new-peer.sh <name> <octet>` → paste the printed `[Peer]`
|
||||||
|
stanza into `peers.yaml` → PR → merge. Phones scan a QR code; desktops import a `.conf`.
|
||||||
|
* **Revoke a device:** delete its stanza → PR → merge. No other step; the key stops working.
|
||||||
|
* **Is it up?** `kubectl --context k3s-prod -n vpn-ingress get ds wireguard` — three Ready.
|
||||||
|
* **Who is connected?** `kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show`
|
||||||
|
(`latest handshake` under ~3 min means live). Never dump `wg showconf` — it prints the private key.
|
||||||
|
* **Full bring-up and verification:** `runbooks/vpn-remote-access.md`.
|
||||||
|
|
||||||
|
## Prerequisites outside this directory
|
||||||
|
|
||||||
|
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
|
||||||
|
(`infra/ansible/roles/node_baseline/defaults/main.yml`); the operator runs the role. The pod has
|
||||||
|
`NET_ADMIN` but deliberately **not** `SYS_MODULE`, so it cannot load the module itself — it
|
||||||
|
CrashLoops with a clear `wg-quick` error instead of quietly degrading to userspace WireGuard.
|
||||||
|
2. **The `wg-server-key` Secret.** Runbook step 1. Until it exists the DaemonSet cannot start —
|
||||||
|
the correct fail-closed state for an unenrolled VPN.
|
||||||
|
3. **The WAN port-forward**, udp/51820 → one node address. Hand-made on the SOHO router, off-repo,
|
||||||
|
and the half CI can never check.
|
||||||
|
|
||||||
|
## Not built (deliberate, with reasons)
|
||||||
|
|
||||||
|
* **No metrics / no peer-level alerting.** A WireGuard exporter would expose
|
||||||
|
`wireguard_latest_handshake_seconds` per peer — genuinely useful for spotting unexpected
|
||||||
|
activity — but it needs a second `NET_ADMIN` container in the pod that terminates the
|
||||||
|
internet-facing tunnel, and that trade was not worth making unverified. Pod-down is already
|
||||||
|
covered generically by `KubeDeploymentReplicasMismatch`/`KubePodNotReady` from
|
||||||
|
kube-prometheus-stack. **This is the top follow-up.**
|
||||||
|
* **No preshared keys.** They would add a symmetric post-quantum hedge on top of the Noise
|
||||||
|
handshake — and would be a real secret, forcing `peers.yaml` into SOPS and costing the
|
||||||
|
reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in the
|
||||||
|
`wg-server-key` Secret.
|
||||||
|
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
|
||||||
|
still reaches IPv6 sites *outside* the tunnel. Functionally harmless, a privacy leak if the point
|
||||||
|
was to hide the client's network. Fix is an `fd..` ULA on `wg0` plus `::/0` in the client's
|
||||||
|
`AllowedIPs`; not done because nothing here needs it.
|
||||||
|
* **No in-cluster-service reach beyond what the LAN has.** Clients reach cluster apps the way a LAN
|
||||||
|
browser does — through the Gateway VIP. Pod IPs and `10.43.0.0/16` service IPs are not routed
|
||||||
|
into the tunnel.
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
---
|
||||||
|
# =================================================================================================
|
||||||
|
# WireGuard remote-access listener — udp/51820 in the HOST network namespace, on every node.
|
||||||
|
# =================================================================================================
|
||||||
|
# WHY WireGuard AND NOT OpenVPN/IPsec. The requirement is "Android, Linux, Windows and other
|
||||||
|
# computing clients", from anywhere on the internet, securely. WireGuard is the only option here
|
||||||
|
# with a first-party client on all four platforms (official apps for Android/iOS/Windows/macOS, and
|
||||||
|
# in-kernel on Linux since 5.6 — so Linux clients need no userspace daemon at all). It is also the
|
||||||
|
# smallest attack surface of the three: one UDP port, ~4k lines of kernel code, no cipher
|
||||||
|
# negotiation, no PKI, no user database, and — the property that actually matters for a port
|
||||||
|
# exposed to the whole internet — it NEVER REPLIES to an unauthenticated packet. See README.
|
||||||
|
#
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# hostNetwork: true — REQUIRED, not convenient. This is THE decision in this file.
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# A VPN client must behave like a machine plugged into the LAN. In Cilium, what a packet is ALLOWED
|
||||||
|
# to reach is decided by the SECURITY IDENTITY of its source, and after any in-pod SNAT that
|
||||||
|
# identity is the VPN pod's — not `world`. That distinction is not academic; it was MEASURED on
|
||||||
|
# this cluster on 2026-10-04 (decisions/2026-10-04-plex-advertise-plexdirect.md): a curl of the
|
||||||
|
# shared Gateway VIP from inside a pod returns `403 Access denied` from Envoy for **all 16**
|
||||||
|
# Gateway hostnames, because Cilium's Gateway preserves the original source identity and enforces
|
||||||
|
# the DESTINATION's ingress CiliumNetworkPolicy, and every app admits `reserved:ingress` plus named
|
||||||
|
# peers — never an arbitrary pod. LAN browsers are allowed only because they arrive as
|
||||||
|
# `reserved:world` / `reserved:host`.
|
||||||
|
#
|
||||||
|
# So a pod-networked WireGuard server would have handed remote clients a tunnel on which every
|
||||||
|
# single cluster web UI returned 403 — plex, gitea, keycloak, nextcloud, grafana, headlamp, the
|
||||||
|
# apex, all of them — while `wg show` reported a perfectly healthy tunnel. PiHole would have failed
|
||||||
|
# the same way and worse: apps/wave-0/pihole-dns.yaml's ingress policy deliberately OMITS the
|
||||||
|
# `cluster` entity, so VPN clients would not have been able to resolve `*.${BASE_DOMAIN}` either.
|
||||||
|
# The VPN would have looked up and been useless, which is the worst failure shape available.
|
||||||
|
#
|
||||||
|
# In the host netns there is no pod identity to carry: forwarded client traffic is SNATed to the
|
||||||
|
# NODE's address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||||
|
# exactly what a LAN client looks like. Three further problems disappear with it:
|
||||||
|
# * net.ipv4.ip_forward is ALREADY 1 on these nodes (node_baseline writes it to
|
||||||
|
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
|
||||||
|
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
|
||||||
|
# required `--allowed-unsafe-sysctls` on all three kubelets (a node-restart change).
|
||||||
|
# * no LoadBalancer VIP, so no CiliumL2AnnouncementPolicy lease lottery (the defect that cost
|
||||||
|
# this LAN an 8-day Minecraft outage) and no `externalTrafficPolicy` trade-off at all.
|
||||||
|
# * no double encapsulation. WireGuard's UDP lands on the node's own NIC, so the underlay is a
|
||||||
|
# flat 1500-byte LAN path, not Cilium's 1450-byte VXLAN.
|
||||||
|
#
|
||||||
|
# hostNetwork is PERMITTED here, and not by accident: platform/kyverno/policies/
|
||||||
|
# restrict-host-namespaces.yaml matches `namespaceSelector: app-namespace=true` only, and names
|
||||||
|
# this exact carve-out in its own comment ("platform components that do -- e.g. Cilium,
|
||||||
|
# node-exporter -- live in platform namespaces that do NOT carry the app-namespace label").
|
||||||
|
# namespace.yaml is deliberately unlabelled. THE COST IS REAL AND IS STATED: a hostNetwork pod
|
||||||
|
# bypasses the Cilium datapath, so NO NetworkPolicy constrains this workload (see
|
||||||
|
# networkpolicy.yaml, which says so rather than pretending otherwise). The boundary protecting
|
||||||
|
# this port is WireGuard's own cryptographic peer authentication, not the CNI.
|
||||||
|
#
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# A DaemonSet, not a single-replica Deployment.
|
||||||
|
# -------------------------------------------------------------------------------------------------
|
||||||
|
# The WAN port-forward on the SOHO router points at ONE node address, by hand, off-repo (same shape
|
||||||
|
# as the plex tcp/32400 forward — runbooks/plex-remote-access.md). A Deployment would float to
|
||||||
|
# whichever node the scheduler picked and silently invalidate that forward on every reschedule. A
|
||||||
|
# DaemonSet instead puts an identical warm listener on ALL THREE nodes: the forward names one of
|
||||||
|
# them, and if that node is lost, FAILOVER IS A ONE-LINE ROUTER CHANGE with no cluster work and no
|
||||||
|
# new client profiles — every node answers the same keys. Each node binds udp/51820 in its own
|
||||||
|
# netns, so there is no conflict, and the two idle listeners cost ~15 MiB each.
|
||||||
|
#
|
||||||
|
# All three nodes carry no taints (measured 2026-10-05), so no tolerations are needed for the
|
||||||
|
# DaemonSet to cover the cluster; add them if a node is ever tainted.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: wireguard
|
||||||
|
namespace: vpn-ingress
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
|
# One node at a time, so a rollout never takes every listener down at once. The node named by the
|
||||||
|
# router's forward does drop for a few seconds during its own replacement; clients re-handshake
|
||||||
|
# automatically (WireGuard retries every 5s, and rekeys every 2 minutes regardless).
|
||||||
|
updateStrategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
|
# Declared for documentation and for symmetry with the app tier. It MUST be `internet`, and
|
||||||
|
# the value is load-bearing rather than cosmetic: platform/vpn-egress/egress-steering.yaml's
|
||||||
|
# CiliumEgressGatewayPolicy selects `egress-class: vpn` pods CLUSTER-WIDE, with no namespace
|
||||||
|
# scope. Setting `vpn` here would steer every remote client's internet traffic out the PIA
|
||||||
|
# tunnel instead of the house WAN — the opposite of the requirement, and it would look like
|
||||||
|
# a working VPN with a mysterious foreign exit IP. (The selector matches on the pod label,
|
||||||
|
# so hostNetwork does not exempt this pod from being selected.)
|
||||||
|
homelab.keithjackson.dev/egress-class: internet
|
||||||
|
# WireGuard authenticates its own peers by public key; there is no UI and no IdP hop.
|
||||||
|
homelab.keithjackson.dev/auth-mode: native
|
||||||
|
spec:
|
||||||
|
# See the long rationale at the head of this file. ClusterFirstWithHostNet keeps in-cluster
|
||||||
|
# DNS working for the pod itself despite hostNetwork (without it the pod inherits the NODE's
|
||||||
|
# resolv.conf, which points at public resolvers).
|
||||||
|
hostNetwork: true
|
||||||
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
# ── Assemble the running config: PUBLIC peer register (git) + PRIVATE server key (Secret) ──
|
||||||
|
# The two halves are deliberately stored apart (peers.yaml explains why) and are only ever
|
||||||
|
# joined here, at pod start, into a tmpfs that is destroyed with the pod. The server's private
|
||||||
|
# key is therefore never written to any node's disk, never committed, and never logged.
|
||||||
|
initContainers:
|
||||||
|
- name: assemble-config
|
||||||
|
image: lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
# POSIX sh. Note on $ handling: this script is rendered through Flux's envsubst (the
|
||||||
|
# `vpn-ingress` Kustomization declares postBuild.substituteFrom), so `${WG_CLIENT_CIDR}`
|
||||||
|
# below IS substituted on purpose — it is a declared cluster-vars knob. Every SHELL
|
||||||
|
# variable is written bare (`$net`, `$(cmd)`), which envsubst leaves untouched. There is
|
||||||
|
# deliberately NO braced shell-variable form anywhere in here: braces are what envsubst
|
||||||
|
# consumes, so a shell variable written that way silently renders to the empty string.
|
||||||
|
# (ci: flux-postbuild-substitution enforces this, and caught this very comment when it
|
||||||
|
# first spelled the hazard out literally — the check reads comments too, correctly.)
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
mkdir -p /config/wg_confs
|
||||||
|
|
||||||
|
# Derive the server's own tunnel address from the single CIDR knob, so there is no
|
||||||
|
# second knob to keep in agreement: 10.90.0.0/24 -> 10.90.0.1/24.
|
||||||
|
net=${WG_CLIENT_CIDR}
|
||||||
|
mask=$(echo "$net" | cut -d/ -f2)
|
||||||
|
base=$(echo "$net" | cut -d/ -f1 | cut -d. -f1-3)
|
||||||
|
|
||||||
|
# The key arrives as a Secret file; strip any trailing newline the operator's shell
|
||||||
|
# added, or wg-quick rejects the config with "Key is not the correct length or format".
|
||||||
|
key=$(tr -d ' \r\n' < /run/wg-server-key/privatekey)
|
||||||
|
if [ -z "$key" ]; then
|
||||||
|
echo "FATAL: /run/wg-server-key/privatekey is empty." >&2
|
||||||
|
echo " See runbooks/vpn-remote-access.md step 1 (create the server key)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '[Interface]'
|
||||||
|
echo "Address = $base.1/$mask"
|
||||||
|
echo 'ListenPort = 51820'
|
||||||
|
# 1420 = 1500 (flat LAN/WAN path to the node's own NIC) - 60 bytes of WireGuard
|
||||||
|
# encapsulation (20 IPv4 + 8 UDP + 32 WireGuard). This is also what wg-quick would
|
||||||
|
# auto-derive, but it is pinned because the symptom of getting it wrong is the
|
||||||
|
# nastiest kind: handshakes and SSH work while large packets (HTTPS, file copies)
|
||||||
|
# hang. Inner packets stay under Cilium's 1450-byte VXLAN MTU too, so reaching a
|
||||||
|
# cluster pod does not fragment either.
|
||||||
|
echo 'MTU = 1420'
|
||||||
|
printf 'PrivateKey = %s\n' "$key"
|
||||||
|
# ── Forwarding + SNAT, applied in the HOST netns when wg0 comes up ──
|
||||||
|
# ip_forward is already 1 from node_baseline; re-asserted so this component does not
|
||||||
|
# silently depend on another role's sysctl file surviving.
|
||||||
|
echo 'PostUp = sysctl -q -w net.ipv4.ip_forward=1'
|
||||||
|
# SNAT client traffic to the node's address. REQUIRED: without it a LAN host would
|
||||||
|
# reply to 10.90.0.x, find no route, hand the packet to the router, and the router
|
||||||
|
# would drop it — so every connection would hang rather than fail. Matching on
|
||||||
|
# `! -o wg0` rather than a named `-o eth0` keeps this independent of the node's NIC
|
||||||
|
# name. Each rule is add-if-absent so a pod killed without PostDown does not
|
||||||
|
# accumulate duplicates in the host's tables across restarts.
|
||||||
|
echo "PostUp = iptables -t nat -C POSTROUTING -s $net ! -o wg0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s $net ! -o wg0 -j MASQUERADE"
|
||||||
|
echo 'PostUp = iptables -C FORWARD -i wg0 -j ACCEPT 2>/dev/null || iptables -A FORWARD -i wg0 -j ACCEPT'
|
||||||
|
echo 'PostUp = iptables -C FORWARD -o wg0 -j ACCEPT 2>/dev/null || iptables -A FORWARD -o wg0 -j ACCEPT'
|
||||||
|
echo "PostDown = iptables -t nat -D POSTROUTING -s $net ! -o wg0 -j MASQUERADE 2>/dev/null || true"
|
||||||
|
echo 'PostDown = iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null || true'
|
||||||
|
echo 'PostDown = iptables -D FORWARD -o wg0 -j ACCEPT 2>/dev/null || true'
|
||||||
|
echo ''
|
||||||
|
} > /config/wg_confs/wg0.conf
|
||||||
|
|
||||||
|
cat /run/wg-peers/peers.conf >> /config/wg_confs/wg0.conf
|
||||||
|
chmod 600 /config/wg_confs/wg0.conf
|
||||||
|
|
||||||
|
# Report the SHAPE of the result, never its content — the file now holds the server's
|
||||||
|
# private key, and a log line is a transcript (CLAUDE.md §6).
|
||||||
|
echo "wg0.conf assembled: $(grep -c '^\[Peer\]' /config/wg_confs/wg0.conf || true) peer(s), server $base.1/$mask, udp/51820."
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 16Mi
|
||||||
|
limits:
|
||||||
|
memory: 64Mi
|
||||||
|
volumeMounts:
|
||||||
|
- { name: wg-config, mountPath: /config }
|
||||||
|
- { name: wg-server-key, mountPath: /run/wg-server-key, readOnly: true }
|
||||||
|
- { name: wg-peers, mountPath: /run/wg-peers, readOnly: true }
|
||||||
|
containers:
|
||||||
|
- name: wireguard
|
||||||
|
# linuxserver/wireguard — provides wg, wg-quick and iptables on a maintained, multi-arch
|
||||||
|
# base. Pinned to the full upstream+build tag (1.0.20260223, published 2026-10-01) rather
|
||||||
|
# than `latest`, per the crafty/oauth2-proxy convention; Renovate maintains it.
|
||||||
|
#
|
||||||
|
# NO `PEERS` ENV IS SET, DELIBERATELY. With PEERS the image runs in "server mode" and
|
||||||
|
# GENERATES keypairs at runtime — which would put the authoritative peer list on a
|
||||||
|
# container filesystem instead of in git, and (worse) print client private keys and QR
|
||||||
|
# codes into the POD LOG, where they would be readable by anyone with log access and
|
||||||
|
# shipped to Loki. Supplying our own /config/wg_confs/wg0.conf selects the image's
|
||||||
|
# bring-up-what-you-are-given path instead.
|
||||||
|
image: lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "America/Chicago"
|
||||||
|
ports:
|
||||||
|
# hostNetwork, so containerPort IS the node port. Declared for documentation and so
|
||||||
|
# `kubectl get pod -o wide` shows what this listener owns.
|
||||||
|
- { name: wireguard, containerPort: 51820, hostPort: 51820, protocol: UDP }
|
||||||
|
securityContext:
|
||||||
|
# NET_ADMIN is the whole privilege budget: create the wg0 link, set the host sysctl, and
|
||||||
|
# write the two netfilter rules above. Everything else is dropped, and the container is
|
||||||
|
# NOT privileged — which also keeps it clear of restrict-host-namespaces' second rule.
|
||||||
|
# SYS_MODULE is deliberately NOT granted: the `wireguard` module is loaded by the node
|
||||||
|
# (infra/ansible/roles/node_baseline, node_baseline_kernel_modules), so this container
|
||||||
|
# never needs to load a kernel module. If the module is missing the pod CrashLoops with
|
||||||
|
# a clear wg-quick error rather than quietly falling back to slow userspace WireGuard.
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
add:
|
||||||
|
- NET_ADMIN
|
||||||
|
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
|
||||||
|
# /var during startup. Same deferral as crafty/headlamp.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
# Kernel WireGuard does its crypto in the KERNEL, not in this container, so throughput
|
||||||
|
# does not consume the container's budget. The limit covers s6 plus the tmpfs holding
|
||||||
|
# wg0.conf (a memory-medium emptyDir counts against this).
|
||||||
|
memory: 128Mi
|
||||||
|
# `wg show wg0` exits non-zero when the interface is absent, which is the one failure that
|
||||||
|
# matters and the one that a port probe cannot see: a crashed or never-created wg0 leaves
|
||||||
|
# udp/51820 silent, and WireGuard is silent to unauthenticated traffic ANYWAY, so "nothing
|
||||||
|
# answers" is indistinguishable from "working" from the outside. This probe is the only
|
||||||
|
# thing that tells the difference.
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command: ["sh", "-c", "wg show wg0 >/dev/null"]
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 24
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["sh", "-c", "wg show wg0 >/dev/null"]
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
volumeMounts:
|
||||||
|
- { name: wg-config, mountPath: /config }
|
||||||
|
# Read-only so the image can SEE whether the kernel module is present and fail with a
|
||||||
|
# real message. It cannot load one (no SYS_MODULE) — that is the node's job.
|
||||||
|
- { name: lib-modules, mountPath: /lib/modules, readOnly: true }
|
||||||
|
volumes:
|
||||||
|
# tmpfs. The assembled wg0.conf contains the server's private key, so it must never land on
|
||||||
|
# a node's disk — `medium: Memory` guarantees that and guarantees it is gone when the pod is.
|
||||||
|
- name: wg-config
|
||||||
|
emptyDir:
|
||||||
|
medium: Memory
|
||||||
|
# 8Mi, not 1Mi: /config is this image's whole config ROOT, not just our one file — its
|
||||||
|
# s6 init also writes templates and state there, and a tmpfs that small would ENOSPC
|
||||||
|
# during startup rather than at a point anyone would connect to the volume size. Still
|
||||||
|
# counted against the container's 128Mi limit, and still far more than wg0.conf needs.
|
||||||
|
sizeLimit: 8Mi
|
||||||
|
# The ONLY secret in this component. Created OUT OF BAND by the operator and referenced by
|
||||||
|
# name (R-IDP-04) — runbooks/vpn-remote-access.md step 1. It is NOT a *.sops.yaml in this
|
||||||
|
# tree on purpose: an agent must not mint a long-lived private key (CLAUDE.md §6), and a key
|
||||||
|
# that passed through a session transcript would have to be treated as compromised.
|
||||||
|
# DR: Velero's `daily` schedule excludes only `monitoring` and `unifi`, so this namespace
|
||||||
|
# and this Secret ARE captured off-cluster (platform/velero/helmrelease.yaml). Keep a copy
|
||||||
|
# in the operator's password manager as well — restoring it is what makes every already
|
||||||
|
# enrolled client work again without re-enrolling.
|
||||||
|
- name: wg-server-key
|
||||||
|
secret:
|
||||||
|
secretName: wg-server-key
|
||||||
|
defaultMode: 0400
|
||||||
|
items:
|
||||||
|
- { key: privatekey, path: privatekey }
|
||||||
|
- name: wg-peers
|
||||||
|
configMap:
|
||||||
|
name: wg-peers
|
||||||
|
- name: lib-modules
|
||||||
|
hostPath:
|
||||||
|
path: /lib/modules
|
||||||
|
type: Directory
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# WireGuard remote-access VPN (north-south INGRESS). Reconciled by its OWN non-gating post-platform
|
||||||
|
# Flux Kustomization (clusters/base/vpn-ingress.yaml, dependsOn platform-config, wait:false) — the
|
||||||
|
# same placement as crafty/headlamp/homepage/runbooks, for the same reason: a leaf whose pod can be
|
||||||
|
# legitimately unhealthy must never sit in the gating `platform` tier, where one component's
|
||||||
|
# unhealth stalls platform-config and every apps wave behind it (DECISIONS-LOG: "Headlamp gating
|
||||||
|
# stall"). That rationale is unusually concrete here: until the operator creates the `wg-server-key`
|
||||||
|
# Secret (runbooks/vpn-remote-access.md step 1) this DaemonSet CANNOT start, so putting it in the
|
||||||
|
# gating tier would have taken the whole platform down on merge.
|
||||||
|
#
|
||||||
|
# NOT in platform/kustomization.yaml — see clusters/base/vpn-ingress.yaml.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
# The peer register: which devices may dial in. PLAINTEXT and reviewable on purpose (see the file).
|
||||||
|
- peers.yaml
|
||||||
|
# The listener itself: hostNetwork DaemonSet, udp/51820 on every node.
|
||||||
|
- daemonset.yaml
|
||||||
|
# Namespace default-deny baseline. NOTE: does NOT constrain the hostNetwork DaemonSet — the file
|
||||||
|
# says so at length rather than implying a protection it cannot provide.
|
||||||
|
- networkpolicy.yaml
|
||||||
|
#
|
||||||
|
# DELIBERATELY ABSENT: a Secret. The server's private key (`wg-server-key`) is created out of band
|
||||||
|
# by the operator and referenced BY NAME from daemonset.yaml. There is no `*.sops.yaml` here, so
|
||||||
|
# this component needs NO SOPS decryption stanza in clusters/dev or clusters/prod — unlike
|
||||||
|
# crafty/headlamp/velero. If that key is ever migrated into SOPS, BOTH halves must land together:
|
||||||
|
# the file here, and a `decryption:` patch naming the `vpn-ingress` Kustomization in each overlay.
|
||||||
|
# Without the patch, kustomize hands Flux the raw ciphertext and the pod mounts a key full of
|
||||||
|
# ENC[...] — which applies cleanly and fails only at handshake time.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
# WireGuard REMOTE-ACCESS VPN (north-south INGRESS) — the counterpart to platform/vpn-egress.
|
||||||
|
#
|
||||||
|
# Do not confuse the two. They are opposite directions and share nothing but the word "VPN":
|
||||||
|
# * platform/vpn-egress — pods OUT to the internet through a PIA tunnel (ADR-0015, R-NET-05/06/07).
|
||||||
|
# * platform/vpn-ingress — remote CLIENTS IN from the internet to this LAN. THIS directory.
|
||||||
|
#
|
||||||
|
# NOTE: intentionally NOT homelab.keithjackson.dev/app-namespace=true. This is PLATFORM network
|
||||||
|
# infrastructure (like cilium / vpn-egress), not an application, so Kyverno does NOT generate the app
|
||||||
|
# default-deny baseline here and the app-scoped auth-mode / egress-class / host-namespace policies do
|
||||||
|
# not apply — every Kyverno policy in platform/kyverno/policies matches namespaceSelector
|
||||||
|
# app-namespace=true. THAT EXEMPTION IS LOAD-BEARING HERE, not incidental: this workload runs with
|
||||||
|
# `hostNetwork: true`, which `restrict-host-namespaces` forbids in an app namespace. It is permitted
|
||||||
|
# here by that policy's OWN stated carve-out — "platform components that do [need host namespaces] --
|
||||||
|
# e.g. Cilium, node-exporter -- live in platform namespaces that do NOT carry the app-namespace
|
||||||
|
# label, so they are out of scope". daemonset.yaml documents why hostNetwork is REQUIRED rather than
|
||||||
|
# convenient; it is the single most consequential decision in this component.
|
||||||
|
#
|
||||||
|
# NO `gateway-access` label. This component serves no HTTP and attaches no HTTPRoute to the shared
|
||||||
|
# Gateway — it is a raw UDP listener. There is deliberately nothing here for the L7 Gateway to route.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: vpn-ingress
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
# =================================================================================================
|
||||||
|
# READ THIS BEFORE TRUSTING THIS FILE: it does NOT constrain the WireGuard listener.
|
||||||
|
# =================================================================================================
|
||||||
|
# Every other platform namespace in this repo (crafty, headlamp, homepage, runbooks) ships an
|
||||||
|
# explicit default-deny baseline as defense in depth, because Kyverno's generator only covers
|
||||||
|
# `app-namespace=true` namespaces. This file keeps that convention — but it would be dishonest to
|
||||||
|
# leave it at that, because the convention does not actually bite here:
|
||||||
|
#
|
||||||
|
# THE `wireguard` DaemonSet RUNS WITH hostNetwork: true, SO IT HAS NO CILIUM ENDPOINT, AND NO
|
||||||
|
# NetworkPolicy OR CiliumNetworkPolicy APPLIES TO IT — NEITHER THIS ONE NOR ANY OTHER.
|
||||||
|
#
|
||||||
|
# That is the documented reason restrict-host-namespaces forbids hostNetwork in app namespaces at
|
||||||
|
# all ("a pod on hostNetwork bypasses the CNI (Cilium) datapath and therefore ESCAPES the
|
||||||
|
# default-deny NetworkPolicy fabric -- it is the most direct way to defeat R-NET-01"). Here that
|
||||||
|
# escape is the deliberate, required design (daemonset.yaml explains why: without it, remote
|
||||||
|
# clients carry a pod identity and are 403'd by every Gateway hostname). A CiliumNetworkPolicy
|
||||||
|
# admitting `world` to udp/51820 would therefore be theatre: it would read as the control that
|
||||||
|
# protects this port, and it would be enforcing nothing. So there isn't one.
|
||||||
|
#
|
||||||
|
# WHAT ACTUALLY PROTECTS THE EXPOSED PORT, then, since the CNI does not:
|
||||||
|
# 1. WireGuard's cryptographic peer authentication. A packet whose key is not in peers.yaml gets
|
||||||
|
# NO REPLY — see README "Why this is safe to expose". This is the real control.
|
||||||
|
# 2. The SOHO router's forward: only udp/51820 is forwarded inbound, to one node.
|
||||||
|
# 3. The blast radius once authenticated is deliberately the whole LAN — that IS the requirement
|
||||||
|
# ("clients should reach the entire local network"). README "What a client can reach" states
|
||||||
|
# the consequence in full, including reach to the nodes' own control plane, and gives the
|
||||||
|
# narrowing recipe for an operator who wants less than that.
|
||||||
|
#
|
||||||
|
# WHAT THIS FILE IS STILL FOR: it is the namespace's standing posture for any FUTURE pod-networked
|
||||||
|
# workload added here (a metrics exporter is the likely one — README "Not built"). Without it, such
|
||||||
|
# a pod would be born unrestricted, because Kyverno will not generate a baseline in a namespace
|
||||||
|
# that is not labelled `app-namespace=true`, and this one deliberately is not.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: default-deny
|
||||||
|
namespace: vpn-ingress
|
||||||
|
labels:
|
||||||
|
homelab.keithjackson.dev/baseline: default-deny
|
||||||
|
spec:
|
||||||
|
podSelector: {}
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
- Egress
|
||||||
|
ingress: []
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
ports:
|
||||||
|
- protocol: UDP
|
||||||
|
port: 53
|
||||||
|
- protocol: TCP
|
||||||
|
port: 53
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================================
|
||||||
|
# THE PEER REGISTER — every device allowed to dial in, in PLAINTEXT, in git, on purpose.
|
||||||
|
# ============================================================================================
|
||||||
|
# WireGuard has no usernames, no passwords and no PKI. Authorisation is exactly one thing: a
|
||||||
|
# device's PUBLIC key appears in this file, or it cannot complete a handshake. An unlisted key
|
||||||
|
# gets no reply at all — not a rejection, silence (see README "Why this is safe to expose").
|
||||||
|
# Revoking a device is therefore deleting its stanza, and nothing else.
|
||||||
|
#
|
||||||
|
# WHY PLAINTEXT, AND WHY THIS IS NOT A SECRET LEAK. A WireGuard peer's PUBLIC key is public by
|
||||||
|
# construction — it is the half you are supposed to hand to the server, and holding it grants
|
||||||
|
# nothing. The only secret in the whole scheme is the SERVER's private key, which is NOT here:
|
||||||
|
# it lives in the `wg-server-key` Secret and is assembled into the running config at pod start
|
||||||
|
# (daemonset.yaml). Keeping the register in cleartext buys the property that matters most for an
|
||||||
|
# access-control list: **"who can get into my house" is reviewable in a pull-request diff**, and
|
||||||
|
# its history is `git log` on this file. A SOPS-encrypted register would hide exactly the change
|
||||||
|
# a reviewer most needs to see — a new line granting a new device entry to the whole LAN.
|
||||||
|
#
|
||||||
|
# This is the same division of labour the repo already uses for the opposite case: a Secret's KEY
|
||||||
|
# NAMES are cleartext while values are encrypted (.sops.yaml `encrypted_regex`), so diffs stay
|
||||||
|
# readable. Here the whole file is the key-name half.
|
||||||
|
#
|
||||||
|
# ADDING A DEVICE — never hand-roll this, and never generate a key in a chat transcript.
|
||||||
|
# ./infra/vpn-ingress/new-peer.sh <device-name> <last-octet>
|
||||||
|
# It generates the keypair ON THE OPERATOR'S MACHINE, writes/renders the client profile (and a
|
||||||
|
# QR code for phones), prints ONLY the public half for pasting below, and never transmits or
|
||||||
|
# logs the private half. Then: add the stanza, PR, merge; Flux rolls the DaemonSet.
|
||||||
|
#
|
||||||
|
# ADDRESS ALLOCATION. ${WG_CLIENT_CIDR} is the tunnel subnet; .1 is the server (every node).
|
||||||
|
# Give each device ONE address as a /32 in AllowedIPs. A /32 is not a formality — on the SERVER
|
||||||
|
# side AllowedIPs is the cryptokey ROUTING table AND the inbound source filter: a peer may only
|
||||||
|
# send from, and receive traffic for, the addresses listed in its own stanza. A sloppy /24 here
|
||||||
|
# would let any one device impersonate every other device's tunnel address.
|
||||||
|
#
|
||||||
|
# .1 the server (this DaemonSet, on every node)
|
||||||
|
# .2 - .254 devices, one each, allocated below. Keep this list dense and in order so the
|
||||||
|
# next free octet is obvious; never re-use an octet after revoking a device
|
||||||
|
# (an old client profile would silently start working again as someone else).
|
||||||
|
#
|
||||||
|
# PRESHARED KEYS are deliberately NOT used. They add a symmetric post-quantum hedge on top of
|
||||||
|
# the Noise handshake and would be a genuine secret — which would force this register into SOPS
|
||||||
|
# and cost the reviewability above. Revisit only if the threat model changes; if it does, the PSK
|
||||||
|
# belongs in the `wg-server-key` Secret, not here. See README "Hardening not taken".
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: wg-peers
|
||||||
|
namespace: vpn-ingress
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
|
data:
|
||||||
|
# Appended VERBATIM to the generated [Interface] block, in this order, at pod start.
|
||||||
|
#
|
||||||
|
# *** THERE ARE NO PEERS YET. THAT IS THE CORRECT INITIAL STATE, NOT AN OVERSIGHT. ***
|
||||||
|
# An agent must not mint key material (CLAUDE.md §6), so no device could be enrolled as part of
|
||||||
|
# authoring this component. A zero-peer WireGuard interface is valid and starts cleanly: the
|
||||||
|
# DaemonSet will run and listen, and every handshake will be refused because no key is trusted.
|
||||||
|
# The VPN is therefore INERT-BUT-HEALTHY until the first stanza lands — fail-closed, and visible
|
||||||
|
# (`wg show` reports an interface with no peers).
|
||||||
|
#
|
||||||
|
# COPY THIS TEMPLATE, uncomment, one block per device:
|
||||||
|
#
|
||||||
|
# # pixel-8 — Keith's phone, enrolled 2026-10-05
|
||||||
|
# [Peer]
|
||||||
|
# PublicKey = <the public key new-peer.sh printed>
|
||||||
|
# AllowedIPs = 10.90.0.2/32
|
||||||
|
#
|
||||||
|
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
|
||||||
|
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
|
||||||
|
# it an incident responder cannot tell which stanza to delete.
|
||||||
|
peers.conf: |
|
||||||
|
# No peers enrolled yet. See the template in this file's comments and
|
||||||
|
# ./infra/vpn-ingress/new-peer.sh. One [Peer] block per device.
|
||||||
+53
-8
@@ -51,6 +51,36 @@ rule groups still evaluate against nothing and **cannot fire**:
|
|||||||
| Flux (`gotk_*`) | `FluxReconciliationFailure`, `FluxResourceSuspended`, `ImageUpdateAutomationFailing`, `ImagePolicyStalled`, `UpdateHealthGateFailed` |
|
| Flux (`gotk_*`) | `FluxReconciliationFailure`, `FluxResourceSuspended`, `ImageUpdateAutomationFailing`, `ImagePolicyStalled`, `UpdateHealthGateFailed` |
|
||||||
| Hubble (`hubble_drop_total`) | `HubbleDeniedFlowSpike` |
|
| Hubble (`hubble_drop_total`) | `HubbleDeniedFlowSpike` |
|
||||||
| etcd (`kubeEtcd` disabled for k3s, deliberately) | no etcd fsync alerting exists at all |
|
| etcd (`kubeEtcd` disabled for k3s, deliberately) | no etcd fsync alerting exists at all |
|
||||||
|
| per-app oauth2-proxy sidecars (`oauth2_proxy_*`) — updated 2026-10-01 | no authentication alerting exists at all for crafty, jackett, pihole, qbittorrent, radarr, sonarr |
|
||||||
|
|
||||||
|
The oauth2-proxy row is the newest, and it has **moved twice in two days** — worth reading in order,
|
||||||
|
because each step looked like the end of it.
|
||||||
|
|
||||||
|
**2026-09-30 — nothing to scrape.** `count by (__name__) ({__name__=~"oauth2_proxy.*"})` returned
|
||||||
|
exactly one family, `oauth2_proxy_requests_in_flight`, from the two replicas of the **shared**
|
||||||
|
forward-auth instance. All six per-app gate sidecars ran without `--metrics-address`, so their metrics
|
||||||
|
listener never started, and no ServiceMonitor or PodMonitor selected them.
|
||||||
|
|
||||||
|
**2026-10-01 — the listener exists, and the scrape is still blocked.** PR #28 added
|
||||||
|
`--metrics-address=0.0.0.0:44180` and a named `metrics` port to the five fleet sidecars, and the
|
||||||
|
endpoint genuinely serves (port-forwarded: `http=200`, 10208 bytes, all three `oauth2_proxy_*`
|
||||||
|
families, `oauth2_proxy_requests_total{code="302"}` present). `platform/oauth2-proxy/podmonitor.yaml`
|
||||||
|
now discovers them by pod label. **But the app namespaces' `default-deny` NetworkPolicy drops the
|
||||||
|
scrape**: from a pod in ns `monitoring`, both `:44180` and `:4180` on the jackett pod time out, while
|
||||||
|
the shared proxy's `:44180` answers — the only difference being that ns `oauth2-proxy` has no
|
||||||
|
NetworkPolicy at all. So the targets are discovered and `up == 0`, and the TSDB is still empty for
|
||||||
|
those namespaces. **This is the same bug Longhorn had**, with the same fingerprint; the fix idiom is
|
||||||
|
`platform/longhorn/networkpolicy-metrics.yaml` (ns monitoring → one pod label → one port), and for
|
||||||
|
these apps it has to be rendered alongside each app's own policy rather than shipped from the
|
||||||
|
oauth2-proxy tier, which is a *sibling* of `apps-wave-0` in the Flux graph and so cannot rely on those
|
||||||
|
namespaces existing yet.
|
||||||
|
|
||||||
|
`OAuth2ProxyGateUnobserved` (`platform/oauth2-proxy/auth-visibility-alerts.yaml`) alerts on the gap
|
||||||
|
itself from `kube_pod_container_info` and `up` — two families that *are* scraped. Note its `up == 1`:
|
||||||
|
a discovered-but-failing target publishes `up` with value **0**, so the earlier bare form would have
|
||||||
|
gone quiet the moment the PodMonitor landed, while nothing was being scraped. That near-miss is the
|
||||||
|
whole reason to distrust a cleared alert here. Crafty remains outside all of this — no metrics
|
||||||
|
listener at all. See `oauth2-proxy-auth-failures`.
|
||||||
|
|
||||||
Each affected runbook says so on its own page and gives the by-hand equivalent. See `targets-down`.
|
Each affected runbook says so on its own page and gives the by-hand equivalent. See `targets-down`.
|
||||||
|
|
||||||
@@ -90,6 +120,9 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
|||||||
- **`vpn-egress-stale`** — the tunnel is UP but `egress-class=vpn` pods have no egress: Cilium's cached
|
- **`vpn-egress-stale`** — the tunnel is UP but `egress-class=vpn` pods have no egress: Cilium's cached
|
||||||
ifindex outlived a reconnect. Also covers the watchdog for that rule going inert.
|
ifindex outlived a reconnect. Also covers the watchdog for that rule going inert.
|
||||||
- **`denied-flows`** — policy-denied flow spike. The Cilium hairpin/identity regression class.
|
- **`denied-flows`** — policy-denied flow spike. The Cilium hairpin/identity regression class.
|
||||||
|
- **`vpn-remote-access`** — the INBOUND remote-access VPN (WireGuard, udp/51820): standing it up,
|
||||||
|
enrolling and revoking devices, and the off-LAN checks that are the only honest proof it works.
|
||||||
|
The opposite direction to the two pages above — do not confuse them.
|
||||||
|
|
||||||
### Backups and DR
|
### Backups and DR
|
||||||
- **`backup-failure`** — a Velero backup failed. The R2 object-tagging incompatibility is documented here.
|
- **`backup-failure`** — a Velero backup failed. The R2 object-tagging incompatibility is documented here.
|
||||||
@@ -108,6 +141,14 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
|||||||
- **`keycloak-db`** — Keycloak's CloudNativePG database is unavailable.
|
- **`keycloak-db`** — Keycloak's CloudNativePG database is unavailable.
|
||||||
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
|
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
|
||||||
every green signal missed.
|
every green signal missed.
|
||||||
|
- **`oauth2-proxy-auth-failures`** — a gated app "is up but does not work". Linked from
|
||||||
|
`OAuth2ProxyGateUnobserved`. The gate answers an unauthenticated **XHR** with a 302 to Keycloak
|
||||||
|
rather than a 401, the browser follows it silently against a live SSO session, and the call
|
||||||
|
resolves **200 carrying HTML** where the app's JavaScript wanted JSON — so the user is never
|
||||||
|
re-prompted and every uptime check stays green. Tells the gate's refusal from the group check
|
||||||
|
from the app's own login, from the sidecar log and from Loki, with the 2026-09-30 jackett trace
|
||||||
|
as the worked example. **Read the caveat**: the per-app gate sidecars export no metrics at all,
|
||||||
|
so these logs (72h of them) are the only record this class of failure leaves.
|
||||||
|
|
||||||
### Operator procedures (no alert links here)
|
### Operator procedures (no alert links here)
|
||||||
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
|
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
|
||||||
@@ -115,14 +156,18 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
|||||||
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
|
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
|
||||||
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
|
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
|
||||||
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
|
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
|
||||||
- **`plex-quicksync`** — hardware transcoding on the host iGPU. GitOps puts `/dev/dri` in the pod
|
- **`plex-quicksync`** — hardware transcoding on the host iGPU. **Confirmed working 2026-10-02**
|
||||||
(`hardware.gpu: intel-quicksync`); **a human still has to tick Settings → Transcoder → "Use hardware
|
(VAAPI decode *and* encode, zero-copy). GitOps requests `gpu.intel.com/i915` on the pod
|
||||||
acceleration when available"**, and no environment variable can do it (the image supports no
|
(`hardware.gpu: intel-quicksync`, satisfied by the `intel-gpu-plugin` DaemonSet — a `/dev/dri`
|
||||||
`PLEX_PREFERENCE*`, verified). The page names the real preference key (`HardwareAcceleratedCodecs`,
|
hostPath was measured not to work: device-cgroup `EPERM`); **a human still has to tick Settings →
|
||||||
read out of the binary), says which widely-cited key **does not exist**, and is explicit that the
|
Transcoder → "Use hardware acceleration when available"**, and no environment variable can do it
|
||||||
**only** proof is `Transcode (hw)` on the Plex dashboard — Plex renders no ServiceMonitor, so no
|
(the image supports no `PLEX_PREFERENCE*`, verified). **The proof is `Plex Media Server.log`'s
|
||||||
alert could ever fire. Also: the device-cgroup failure to expect first and its device-plugin
|
`final decoder: / final encoder:` line, not the Plex dashboard** — the dashboard showed nothing
|
||||||
fallback, and why a `:latest` pull can silently break it.
|
while hardware transcoding was working, so its silence proves nothing; the page carries the
|
||||||
|
redacting grep (`X-Plex-Token` lives in that log), the WRONG log file to avoid, and two more
|
||||||
|
documented false leads (an empty `/proc/*/fd` scan, and a missing `HardwareAcceleratedCodecs` key).
|
||||||
|
Plex renders no ServiceMonitor, so no alert could ever fire. Also: **`Pending` stalls the whole app
|
||||||
|
tier** and its one-field escape hatch, and why a `:latest` pull can silently break it.
|
||||||
- **`oauth2-proxy-session`** — a gated UI loads but its saves quietly do nothing, and you are never
|
- **`oauth2-proxy-session`** — a gated UI loads but its saves quietly do nothing, and you are never
|
||||||
re-prompted to log in. Tells the three stacked auth layers apart from the sidecar's own access log
|
re-prompted to log in. Tells the three stacked auth layers apart from the sidecar's own access log
|
||||||
(the four lines that matter, including the upstream `-` that means the proxy answered a request the
|
(the four lines that matter, including the upstream `-` that means the proxy answered a request the
|
||||||
|
|||||||
@@ -85,10 +85,17 @@ configMapGenerator:
|
|||||||
- node-io=node-io.md
|
- node-io=node-io.md
|
||||||
- node-memory=node-memory.md
|
- node-memory=node-memory.md
|
||||||
- node-memory-swap=node-memory-swap.md
|
- node-memory-swap=node-memory-swap.md
|
||||||
|
# Linked from OAuth2ProxyGateUnobserved, and the diagnostic procedure for any gated app
|
||||||
|
# that "is up but does not work" — the gate answers an unauthenticated XHR with a 302,
|
||||||
|
# not a 401, so the failure arrives as an HTTP 200 carrying the wrong body.
|
||||||
|
- oauth2-proxy-auth-failures=oauth2-proxy-auth-failures.md
|
||||||
# Operator procedure, not alert-linked. Reading the per-app oauth2-proxy sidecar log to tell
|
# Operator procedure, not alert-linked. Reading the per-app oauth2-proxy sidecar log to tell
|
||||||
# Keycloak, the sidecar and the app's own login apart. Cookie collision between two sidecars
|
# Keycloak, the sidecar and the app's own login apart. Cookie collision between two sidecars
|
||||||
# presents as random session loss in ONE app's log and is only legible with both side by side,
|
# presents as random session loss in ONE app's log and is only legible with both side by side,
|
||||||
# so the page has to be reachable at 3am without going looking for it in git.
|
# so the page has to be reachable at 3am without going looking for it in git.
|
||||||
|
# SIBLING PAGE to oauth2-proxy-auth-failures above, and they are deliberately both here: this
|
||||||
|
# one is the SESSION-LIFECYCLE procedure (why a session died), that one is the DIAGNOSTIC
|
||||||
|
# TRIAGE (which of the three layers refused, and how to tell a 302-to-200 from a real success).
|
||||||
- oauth2-proxy-session=oauth2-proxy-session.md
|
- oauth2-proxy-session=oauth2-proxy-session.md
|
||||||
# Operator procedure, not an alert page (no alert links to it — the
|
# Operator procedure, not an alert page (no alert links to it — the
|
||||||
# alert-runbooks-resolvable check treats that as advisory). It is served
|
# alert-runbooks-resolvable check treats that as advisory). It is served
|
||||||
@@ -106,3 +113,8 @@ configMapGenerator:
|
|||||||
- update-rollback=update-rollback.md
|
- update-rollback=update-rollback.md
|
||||||
- vpn-down=vpn-down.md
|
- vpn-down=vpn-down.md
|
||||||
- vpn-egress-stale=vpn-egress-stale.md
|
- vpn-egress-stale=vpn-egress-stale.md
|
||||||
|
# Operator procedure, not an alert page. Served because the component it completes is INERT
|
||||||
|
# until three off-repo manual steps are done (kernel module, server key, WAN port-forward) —
|
||||||
|
# and because the one check that actually proves it cannot be run from inside the house.
|
||||||
|
# Peer-level alerting does not exist yet (it needs an exporter); this page is the substitute.
|
||||||
|
- vpn-remote-access=vpn-remote-access.md
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# oauth2-proxy auth failures — telling the three layers apart
|
||||||
|
|
||||||
|
Linked from `OAuth2ProxyGateUnobserved`, and the page to open whenever a gated app "is up but
|
||||||
|
does not work": a UI that loads and then shows nothing, a page that never finishes, a list that
|
||||||
|
stays empty, a save that silently does not save. The alert is about the *visibility* gap; this
|
||||||
|
page is the diagnostic procedure, and it works whether or not the alert ever fired.
|
||||||
|
|
||||||
|
## What this means here
|
||||||
|
|
||||||
|
Every app behind Keycloak on this platform has **three** places a request can be refused, and
|
||||||
|
from a browser they look identical — because two of the three answer with **HTTP 200**.
|
||||||
|
|
||||||
|
| Layer | What refuses | What the browser sees |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. The gate (oauth2-proxy sidecar) | no valid session cookie | **302 to Keycloak**, then usually **200** |
|
||||||
|
| 2. The gate's group check | session valid, wrong group | 403, or a redirect loop |
|
||||||
|
| 3. The app itself | the app's own login / API key | the app's own 401 or login page |
|
||||||
|
|
||||||
|
**Layer 1 is the one that hides.** oauth2-proxy answers an unauthenticated request with a
|
||||||
|
redirect, not a 401 — correct for a browser navigation, disastrous for an **XHR**. The app's
|
||||||
|
JavaScript asks for JSON, the gate answers `302`, the browser follows it silently, the live
|
||||||
|
Keycloak SSO session satisfies it with no prompt, and the XHR resolves **200 with an HTML
|
||||||
|
dashboard in the body**. `fetch()` sees a success. The user sees an empty page. Reloading does
|
||||||
|
nothing, and nobody is ever asked to log in, because from the browser's point of view nothing
|
||||||
|
failed.
|
||||||
|
|
||||||
|
The worked example, jackett on 2026-09-30, straight from the sidecar log:
|
||||||
|
|
||||||
|
[oauthproxy.go:1026] No valid authentication in request. Initiating login.
|
||||||
|
GET "/api/v2.0/indexers/yts/config?_=..." HTTP/1.1 ... 302 400
|
||||||
|
GET "/api/v2.0/indexers/thepiratebay/config?_=..." HTTP/1.1 ... 302 409
|
||||||
|
... ten of these at 15:30:53
|
||||||
|
[AuthSuccess] Authenticated via OAuth2: Session{email:... created:2026-09-30 15:31:06
|
||||||
|
expires:2026-09-30 15:36:06 refresh_token:true groups:[platform-admins]}
|
||||||
|
GET "/UI/Dashboard" 200 44379
|
||||||
|
GET "/api/v2.0/indexers?_=..." 200 1501229
|
||||||
|
... and 26 seconds later, inside that same 5-minute session:
|
||||||
|
[oauthproxy.go:1026] No valid authentication in request. Initiating login.
|
||||||
|
|
||||||
|
A session created at 15:31:06, valid until 15:36:06, not honoured at 15:31:32. That is the
|
||||||
|
signature of the session being **re-established over and over instead of held** — a cookie that
|
||||||
|
another proxy is overwriting, or a cookie that never comes back at all — not of Keycloak being
|
||||||
|
down and not of the app being broken.
|
||||||
|
|
||||||
|
**Read this before you trust any silence (updated 2026-10-01).** The five fleet gate sidecars now
|
||||||
|
*do* run a metrics listener on `:44180`, and `platform/oauth2-proxy/podmonitor.yaml` discovers
|
||||||
|
them — but **the scrape is dropped by the app namespaces' `default-deny` NetworkPolicy**, so those
|
||||||
|
targets read `up == 0` and the TSDB holds nothing for them. Crafty has no listener at all. Only
|
||||||
|
the shared `oauth2-proxy` namespace instance is genuinely scraped, and it is not in the data path
|
||||||
|
for any of these apps. So there is still nothing to graph and nothing that could have alerted.
|
||||||
|
**The logs below remain the only record that this class of failure happened.** Check which state
|
||||||
|
you are in before you trust a quiet dashboard — "First commands" step 1 tells you in one query.
|
||||||
|
|
||||||
|
## First commands
|
||||||
|
|
||||||
|
`kubectl` does not exist on the workstation; everything runs in the pinned container, and always
|
||||||
|
with an explicit context.
|
||||||
|
|
||||||
|
**1. Which namespaces have an unobserved gate** (this is what the alert measures):
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 \
|
||||||
|
-c prometheus -- /bin/promtool query instant http://localhost:9090 \
|
||||||
|
"count by (namespace) (kube_pod_container_info{container=\"oauth2-proxy\"}) unless count by (namespace) (up{container=\"oauth2-proxy\"} == 1)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**1b. If a namespace is listed, is it "no target" or "target failing"?** The two need different
|
||||||
|
fixes and the alert deliberately does not distinguish them:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n monitoring exec prometheus-kube-prometheus-stack-prometheus-0 \
|
||||||
|
-c prometheus -- /bin/promtool query instant http://localhost:9090 \
|
||||||
|
"up{container=\"oauth2-proxy\"}"'
|
||||||
|
```
|
||||||
|
|
||||||
|
No series for the namespace → nothing discovers it (no PodMonitor match; check the pod carries
|
||||||
|
`homelab.keithjackson.dev/auth-mode=forward-auth` and a named `metrics` port). A series with
|
||||||
|
value **0** → discovered but every scrape fails; get the reason from the target's `lastError`:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n monitoring port-forward svc/kube-prometheus-stack-prometheus 19090:9090 & sleep 5; \
|
||||||
|
curl -s localhost:19090/api/v1/targets | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
for t in json.load(sys.stdin)[\"data\"][\"activeTargets\"]:
|
||||||
|
if t[\"labels\"].get(\"container\")==\"oauth2-proxy\":
|
||||||
|
print(t[\"labels\"].get(\"namespace\"), t[\"health\"], t.get(\"lastError\",\"\")[:90])"'
|
||||||
|
```
|
||||||
|
|
||||||
|
`context deadline exceeded` is a **drop**, not a refusal — that is NetworkPolicy, not a dead
|
||||||
|
endpoint. Confirm the endpoint itself is fine by port-forwarding straight to the pod, which
|
||||||
|
bypasses policy entirely:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n jackett port-forward \
|
||||||
|
$(kubectl --context k3s-prod -n jackett get pod -l app.kubernetes.io/name=jackett -o name | head -1) \
|
||||||
|
14418:44180 & sleep 5; curl -s -o /dev/null -w "http=%{http_code} bytes=%{size_download}\n" \
|
||||||
|
http://127.0.0.1:14418/metrics'
|
||||||
|
```
|
||||||
|
|
||||||
|
`http=200` with a few KB means the listener is healthy and the path is what is broken. As of
|
||||||
|
2026-10-01 that is the state of all five fleet apps, and the fix is an ingress allowance from ns
|
||||||
|
`monitoring` to port 44180 — see `platform/longhorn/networkpolicy-metrics.yaml` for the idiom.
|
||||||
|
|
||||||
|
**2. The sidecar log — but you must grep it.** The gate's stdout is ~11,500 lines a day per app
|
||||||
|
and almost all of it is the kubelet health probe (`GET "/ping" ... 200`). A plain
|
||||||
|
`kubectl logs --tail=100` shows you nothing but `/ping` and looks perfectly healthy. Filter:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n jackett logs deploy/jackett -c oauth2-proxy --since=2h \
|
||||||
|
| grep -v "/ping" | grep -E "No valid authentication|AuthSuccess|403|401|callback"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute the namespace: `crafty`, `jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`.
|
||||||
|
|
||||||
|
**3. The same question across every gate at once, from Loki.** Alloy collects all of these, so
|
||||||
|
you can ask about the whole fleet and about a window that has already scrolled out of
|
||||||
|
`kubectl logs`. Port-forward Loki and query it:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n monitoring port-forward svc/loki 13100:3100 & sleep 5; \
|
||||||
|
curl -s -G --data-urlencode "query=sum by (namespace) (count_over_time({container=\"oauth2-proxy\"} |= \"No valid authentication in request\" [24h]))" \
|
||||||
|
http://127.0.0.1:13100/loki/api/v1/query | python3 -m json.tool | grep -E "namespace|value" '
|
||||||
|
```
|
||||||
|
|
||||||
|
and the denominator, which is what makes the number mean something:
|
||||||
|
|
||||||
|
```
|
||||||
|
... --data-urlencode "query=sum by (namespace) (count_over_time({container=\"oauth2-proxy\"} |= \"AuthSuccess\" [24h]))" ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to read the ratio.** A healthy sign-in is roughly **one or two** `No valid authentication`
|
||||||
|
lines per `AuthSuccess` — the first request of a session is unauthenticated by definition. A
|
||||||
|
ratio in the tens means sessions are not surviving. Measured on prod 2026-09-30 (24h):
|
||||||
|
|
||||||
|
| namespace | `No valid authentication` | `AuthSuccess` | ratio |
|
||||||
|
|---|---|---|---|
|
||||||
|
| crafty | 81 | 1 | 81:1 |
|
||||||
|
| radarr | 67 | 1 | 67:1 |
|
||||||
|
| jackett | 39 | 4 | ~10:1 |
|
||||||
|
| pihole, qbittorrent, sonarr | 0 | 0 | (unused in the window) |
|
||||||
|
|
||||||
|
All three of those are the failure, not the baseline.
|
||||||
|
|
||||||
|
**The cleanest worked case of all — radarr, measured 2026-10-01.** After the cookie fix landed,
|
||||||
|
jackett dropped to **0** `No valid authentication` lines in 13,612 (from 39), and `removing
|
||||||
|
session` / `cookie signature not valid` went to **0 on both apps** (radarr had 8 and 8 before).
|
||||||
|
The eviction fingerprint is gone. But radarr still logged **206** rejections — and sampling 209 of
|
||||||
|
them, **every single one was the same request**:
|
||||||
|
|
||||||
|
GET "/signalr/messages/negotiate" ... 302 <- browser UA, over and over
|
||||||
|
|
||||||
|
That is one left-open browser tab. Its cookie is gone, its SignalR socket keeps trying to
|
||||||
|
reconnect, and every attempt is silently 302'd to Keycloak. Nobody is at the keyboard, so nobody
|
||||||
|
is ever re-prompted; the tab just sits there looking like a working page that has stopped
|
||||||
|
updating. **This is the entire failure class in one line**: a non-navigation request answered with
|
||||||
|
a redirect, forever, with no error surfaced anywhere.
|
||||||
|
|
||||||
|
So when you read a rejection count, **always group by request path before concluding anything**:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n radarr logs deploy/radarr -c oauth2-proxy --since=24h \
|
||||||
|
| grep -v "/ping" | grep "No valid authentication" -A 1 \
|
||||||
|
| grep -oE "\"[^\"]+\" HTTP" | sort | uniq -c | sort -rn | head'
|
||||||
|
```
|
||||||
|
|
||||||
|
One path dominating means an orphaned client, not a broken gate — annoying but harmless, and it
|
||||||
|
clears when the tab is closed. Many different paths from one session means the session itself is
|
||||||
|
not surviving, which is the real defect. A per-app `--api-route` is the structural fix for the
|
||||||
|
first shape: it makes those paths answer **401** instead of 302, so the client fails loudly
|
||||||
|
instead of looping. Note that a count of **0 `AuthSuccess`** alongside a high rejection count
|
||||||
|
means nobody has signed in since the last restart — so `--cookie-refresh` has never run, and no
|
||||||
|
session-lifecycle conclusion can be drawn from that window at all.
|
||||||
|
|
||||||
|
**4. Did the gate admit the user, or did the app refuse them?** Look for the `[AuthSuccess]` line
|
||||||
|
and what follows it. If `[AuthSuccess]` appears and the very next thing is the *app's own* login
|
||||||
|
page or the app's 401, the gate did its job and layer 3 is your problem — that is the Crafty
|
||||||
|
shape, and `runbooks/crafty-admin-credential.md` covers it.
|
||||||
|
|
||||||
|
**5. Cookie names, when sessions do not survive.** Two oauth2-proxy instances emitting the
|
||||||
|
**same** cookie name on the **same** domain hold different cookie-secrets, so each overwrites the
|
||||||
|
other's cookie and the loser logs exactly the `No valid authentication` line above. List what
|
||||||
|
each gate is configured to emit (arguments only — never print a Secret's data):
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'for ns in crafty jackett pihole qbittorrent radarr sonarr; do printf "%-12s " "$ns"; \
|
||||||
|
kubectl --context k3s-prod -n $ns get deploy $ns \
|
||||||
|
-o jsonpath="{range .spec.template.spec.containers[?(@.name==\"oauth2-proxy\")]}{.args}{end}" \
|
||||||
|
| tr "," "\n" | grep -E "cookie-name|cookie-domain" | tr "\n" " "; echo; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
A gate with no `--cookie-name` is using oauth2-proxy's default `_oauth2_proxy`. If two of them
|
||||||
|
share that default **and** a zone-wide `--cookie-domain=.home.keithjackson.dev`, they are
|
||||||
|
evicting each other, and the symptom is intermittent and depends on which app you visited last.
|
||||||
|
|
||||||
|
## Likely causes, most likely first
|
||||||
|
|
||||||
|
1. **Cookie collision between two gates on one domain.** Default cookie names plus a zone-wide
|
||||||
|
cookie domain. Signature: high `No valid authentication`-to-`AuthSuccess` ratio, sessions
|
||||||
|
dying seconds after being created, symptom depends on browsing order, and *nothing* wrong at
|
||||||
|
Keycloak. Fix is a unique `--cookie-name` and a host-scoped `--cookie-domain` per gate — see
|
||||||
|
`decisions/2026-09-29-crafty-oauth2-proxy-signin.md`, which found and fixed exactly this.
|
||||||
|
2. **The cookie is too large to come back.** With `--pass-access-token` the session carries the
|
||||||
|
access token; a fat Keycloak token pushes the cookie past 4 KB and it gets split into
|
||||||
|
`_oauth2_proxy_0`, `_oauth2_proxy_1`… If a chunk is dropped the session is unreadable and the
|
||||||
|
gate logs the same line. Look for numbered cookie names in the log, and for a session that
|
||||||
|
never survives a single navigation.
|
||||||
|
3. **The XHR path is not the navigation path.** The app's API is under a prefix the gate handles
|
||||||
|
differently, or the app issues cross-origin XHRs the cookie does not accompany. Signature:
|
||||||
|
`/UI/...` returns 200 and `/api/...` returns 302 in the *same second*, which is precisely the
|
||||||
|
jackett trace above.
|
||||||
|
4. **Session lifetime shorter than the app's polling interval,** with no refresh. The jackett
|
||||||
|
session above was a 5-minute session. If `refresh_token:true` is present and sessions still
|
||||||
|
die early, the refresh is failing, not missing.
|
||||||
|
5. **Group check, not authentication.** `--allowed-group` is a floor: a valid Keycloak session in
|
||||||
|
the wrong group is refused *after* a successful login, which reads as a loop. Look for 403 and
|
||||||
|
for the `groups:[...]` list on the `[AuthSuccess]` line.
|
||||||
|
6. **The app, not the gate.** `[AuthSuccess]` followed by the app's own login page or 401.
|
||||||
|
|
||||||
|
## When to escalate or stop
|
||||||
|
|
||||||
|
- **Stop and hand over if the fix is a manifest change.** Everything above is read-only
|
||||||
|
diagnosis. Changing a `--cookie-name`, adding `--metrics-address`, or editing an alert goes
|
||||||
|
through a branch and a PR; `kubectl edit` on a gate is drift that the next Flux reconcile
|
||||||
|
reverts, and in the meantime you have an unreproducible cluster.
|
||||||
|
- **Stop if you find yourself about to print a Secret.** The cookie-secret and client-secret live
|
||||||
|
in `oauth2-proxy-credentials` and the per-app equivalents. Compare *key names* and whether two
|
||||||
|
Secrets differ; never dump `.data` or `.stringData`. A password has been leaked into a
|
||||||
|
transcript here exactly this way.
|
||||||
|
- **Escalate to the platform owner if Keycloak itself is the suspect** — check `KeycloakDown` and
|
||||||
|
`KeycloakDatabaseDown` first (`runbooks/keycloak-down.md`, `runbooks/keycloak-db.md`). In the
|
||||||
|
2026-09-30 incident Keycloak was healthy throughout and blaming it would have cost days.
|
||||||
|
- **The honest limit of this page.** With no metrics from the sidecars there is no history beyond
|
||||||
|
Loki's **72h** retention (`platform/monitoring/loki.yaml`, `retention_period: 72h`). If the
|
||||||
|
report is older than three days, the evidence is gone and the only remaining move is to
|
||||||
|
reproduce it. That is the cost of the gap `OAuth2ProxyGateUnobserved` is tracking, and it is
|
||||||
|
the reason to close it rather than silence it.
|
||||||
+375
-97
@@ -1,14 +1,33 @@
|
|||||||
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
||||||
|
|
||||||
|
**Status: hardware transcoding is CONFIRMED WORKING on prod as of 2026-10-02** — VAAPI for both
|
||||||
|
decode *and* encode, zero-copy, measured in Plex's own server log (see "How to verify"). The manual
|
||||||
|
toggle has been ticked. This page is therefore now mostly a **verification and regression**
|
||||||
|
procedure, and its old verification section was **wrong** — see the correction at the top of
|
||||||
|
"How to verify".
|
||||||
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
|
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
|
||||||
not an incident page. It is indexed here because the feature it completes is **inert until a human
|
not an incident page. It is indexed here because the feature it completes is **inert until a human
|
||||||
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
|
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
|
||||||
to prevent.
|
to prevent.
|
||||||
**Files:** `catalog/plex.yaml` (`hardware.gpu: intel-quicksync`), `apps/wave-0/plex.yaml` (the
|
**Files:** `catalog/plex.yaml` (`hardware.gpu: intel-quicksync`), `apps/wave-0/plex.yaml` (the
|
||||||
rendered golden — the `dri` hostPath volume and the `/dev/dri` mount), `catalog/render.py` (the
|
rendered golden — the `resources.limits: gpu.intel.com/i915: 1` block), `catalog/render.py` (the
|
||||||
mechanism), `schemas/renderer-contract.md` `### hardware` (the contract).
|
mechanism), `schemas/renderer-contract.md` `### hardware` (the contract),
|
||||||
|
`platform/intel-gpu-plugin/` (the DaemonSet that advertises the resource).
|
||||||
**Related:** `runbooks/plex-claim.md` — the other Plex procedure with a manual last mile.
|
**Related:** `runbooks/plex-claim.md` — the other Plex procedure with a manual last mile.
|
||||||
|
|
||||||
|
## STOP HERE IF PLEX IS `Pending` / `Unschedulable`
|
||||||
|
|
||||||
|
That is this feature's **one serious failure mode** and it **stalls the whole app tier**
|
||||||
|
(`apps-wave-0` is `wait: true`; `apps-wave-1` `dependsOn` it). Since 2026-10-01 Plex requests the
|
||||||
|
extended resource `gpu.intel.com/i915: 1`, which is a **hard scheduling constraint** — if the
|
||||||
|
`intel-gpu-plugin` DaemonSet is not advertising it on at least one schedulable node, Plex does not
|
||||||
|
start at all. Go to **"Plex will not schedule"** below; it is the first troubleshooting section on
|
||||||
|
purpose.
|
||||||
|
|
||||||
|
**The escape hatch, in one sentence:** delete the two `hardware:` lines from `catalog/plex.yaml`,
|
||||||
|
re-render, and merge — Plex returns to software transcoding and the tier unblocks. Exact commands
|
||||||
|
are in **"Plex will not schedule" → remedy B**.
|
||||||
|
|
||||||
## What this means here
|
## What this means here
|
||||||
|
|
||||||
Plex transcodes in **software** by default. On the UHD Graphics 630 iGPU of an i7-9700T, Quick Sync
|
Plex transcodes in **software** by default. On the UHD Graphics 630 iGPU of an i7-9700T, Quick Sync
|
||||||
@@ -18,8 +37,8 @@ is in git**:
|
|||||||
|
|
||||||
| Half | Who does it | State |
|
| Half | Who does it | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| The render device is inside the container at `/dev/dri` | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders a `dri` hostPath volume (`/dev/dri`, `type: DirectoryOrCreate`) and a read-write `/dev/dri` volumeMount on the app container. Flux applies it. | Done by merging that change. |
|
| The render device is inside the container **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. |
|
||||||
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **NOT done.** `HardwareAcceleratedCodecs` was **absent** from `Preferences.xml` when this page was written — the toggle has never been on. |
|
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **Done, 2026-10-02**, and confirmed from the **log** rather than from a preference key. `HardwareAcceleratedCodecs` is **still absent** from `Preferences.xml` with hardware transcoding working, so its absence proves nothing — see first-command #4. |
|
||||||
|
|
||||||
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
|
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
|
||||||
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
|
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
|
||||||
@@ -30,10 +49,44 @@ Every `setPref` the image performs is one of six hardcoded keys (`MachineIdentif
|
|||||||
and no `postBuild` substitution can set it. So do not go looking for the clever way to automate this;
|
and no `postBuild` substitution can set it. So do not go looking for the clever way to automate this;
|
||||||
there isn't one, and the search has already been done.
|
there isn't one, and the search has already been done.
|
||||||
|
|
||||||
What the device mount does **not** do, so nobody reads more into a green gate than is there: it does
|
### Why a resource request and not a `/dev/dri` hostPath
|
||||||
not enable hardware transcoding, it does not prove the device can be *opened* from inside a
|
|
||||||
non-privileged pod (see "Likely causes" #2), and it renders no `nodeSelector` — Plex can land on any
|
Because the hostPath was **tried and measured not to work**, and knowing that is the only thing that
|
||||||
of the three nodes and all three carry the device.
|
will stop someone "simplifying" it back. Merged 2026-09-30, measured in the live pod on k3s-prod-2 on
|
||||||
|
**2026-10-01**: the device node was plainly **visible** in the container
|
||||||
|
(`crw-rw---- root video2 226,128 renderD128`) and **could not be opened** —
|
||||||
|
|
||||||
|
| attempt | result |
|
||||||
|
|---|---|
|
||||||
|
| `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
| `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
|
||||||
|
`EPERM` is the **device cgroup** refusing, not `EACCES` from file permissions. The two look identical
|
||||||
|
from the outside and are not the same problem. Proven three ways: root on the **host** opened the
|
||||||
|
same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc `config.json` read
|
||||||
|
`linux.resources.devices: [{allow: False, access: 'rwm'}]` with `linux.devices: None` — **one
|
||||||
|
deny-all rule, no allow entry for major 226**. A hostPath is a *filesystem* operation: it binds the
|
||||||
|
device node in and adds **no cgroup allow rule**, because Kubernetes cannot infer one from a volume.
|
||||||
|
|
||||||
|
Two non-workarounds, so nobody spends an evening on them: **`uid 0` failed too**, so running as root
|
||||||
|
does not bypass the cgroup gate; and **`privileged: true` is not available** — Kyverno
|
||||||
|
`restrict-host-namespaces` is **Enforce** on app namespaces and rejects it.
|
||||||
|
|
||||||
|
The device plugin is what produces the allow rule — in the v0.37.0 source,
|
||||||
|
`createDeviceSpecsFromDrmFiles` emits `DeviceSpec{…, Permissions: "rw"}`, which the kubelet turns
|
||||||
|
into both the device injection and the matching cgroup allow.
|
||||||
|
|
||||||
|
**The failure mode that trade bought, stated plainly.** Before, a missing device meant the pod ran
|
||||||
|
and **silently software-transcoded** — invisible, but harmless to everything else. Now, a missing
|
||||||
|
*resource* means Plex is **Unschedulable** and the app tier stalls. That is strictly worse in blast
|
||||||
|
radius and it is **unavoidable**: an extended resource is a hard scheduling constraint, and it is
|
||||||
|
also the only thing that produces the cgroup allow rule. There is no graceful-degradation variant.
|
||||||
|
The one improvement is that the loss is no longer silent.
|
||||||
|
|
||||||
|
What this does **not** do, so nobody reads more into a green gate than is there: it does not enable
|
||||||
|
hardware transcoding (that is the manual toggle below — done 2026-10-02), a green gate only proves
|
||||||
|
the **manifest renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
|
||||||
|
advertises, which is the scheduler's doing and not a selector in this repo.
|
||||||
|
|
||||||
## Running the commands in this runbook
|
## Running the commands in this runbook
|
||||||
|
|
||||||
@@ -51,15 +104,46 @@ read of that file below greps a single named key — do not widen one.
|
|||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
**1. Is the device mounted into the pod at all?** (Proves Flux applied the change, not that it works.)
|
**0. Is Plex running at all?** Ask this first now — the current mechanism can stop it starting.
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide'
|
||||||
|
```
|
||||||
|
|
||||||
|
`Pending` ⇒ go straight to **"Plex will not schedule"**. Running ⇒ carry on.
|
||||||
|
|
||||||
|
**1. Do the nodes advertise the resource, and is the plugin Ready?** This replaces the old "is the
|
||||||
|
hostPath mounted" check, and it is now the first thing that can be wrong.
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod get nodes -o json \
|
||||||
|
| jq -r ".items[] | \"\(.metadata.name) i915=\(.status.allocatable[\"gpu.intel.com/i915\"] // \"ABSENT\")\""'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `i915=1` on each of the three prod nodes (measured 2026-10-01: all three). Any `ABSENT` ⇒ that
|
||||||
|
node cannot run Plex. Then the DaemonSet behind it:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n intel-gpu-plugin get daemonset intel-gpu-plugin'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect **3 desired / 3 ready / 3 available** (measured 2026-10-01). Anything less is the cause, not a
|
||||||
|
symptom — fix it there, in `platform/intel-gpu-plugin/`, not here.
|
||||||
|
|
||||||
|
**2. Did Flux apply the current manifest?** `gpu.intel.com/i915` on the app container means the
|
||||||
|
GitOps half landed:
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
|
||||||
-o jsonpath="{.items[0].spec.containers[0].volumeMounts[*].mountPath}"; echo'
|
-o jsonpath="{.items[0].spec.containers[0].resources.limits}"; echo'
|
||||||
```
|
```
|
||||||
|
|
||||||
`/dev/dri` in that list ⇒ the GitOps half landed. Absent ⇒ the running pod predates the merge; compare
|
A `/dev/dri` **volumeMount** instead ⇒ the running pod predates the 2026-10-01 merge and is on the
|
||||||
|
mechanism that does not work. Compare
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
@@ -69,46 +153,52 @@ infra/devcontainer/dev.sh run bash -c \
|
|||||||
|
|
||||||
against the merge commit before blaming anything else.
|
against the merge commit before blaming anything else.
|
||||||
|
|
||||||
**2. Is the device actually visible inside the container?**
|
**3. Is the device visible inside the container, and can Plex *open* it?** This is the decisive test
|
||||||
|
of the whole mechanism, and **it must be run as uid 1000**, because that is what Plex runs as.
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri'
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri'
|
||||||
```
|
```
|
||||||
|
|
||||||
Expect `renderD128` (usually `card0` too), mode `crw-rw----`, group `render`. An **empty directory**
|
Expect `renderD128`, mode `crw-rw----`. The plugin injects it; there is no longer any
|
||||||
here is the `DirectoryOrCreate` degradation: the kubelet created an empty `/dev/dri` because the node
|
`DirectoryOrCreate` empty-directory case — if the resource were missing the pod would not be running
|
||||||
had none. That is the designed-safe outcome — the app tier still reconciles — but it means software
|
to exec into. Then the open, **as the app user**:
|
||||||
transcoding, silently. Find which node the pod is on and ask why that node lost its iGPU:
|
|
||||||
|
|
||||||
```
|
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
|
||||||
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide'
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Can Plex *open* it?** This is the question the manifest cannot answer — see "Likely causes" #2.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
||||||
bash -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"'
|
s6-setuidgid plex sh -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"'
|
||||||
```
|
```
|
||||||
|
|
||||||
`RW-OPEN-DENIED` (or `Operation not permitted` / `Permission denied`) with the device plainly visible
|
`RW-OPEN-OK` **as uid 1000** is the pass condition. **Passing as `uid 0` is weaker evidence and must
|
||||||
in step 2 is the **device-cgroup** failure, not a file-permission failure. The two look identical from
|
not be accepted as proof** — Plex runs as 1000, and the whole point of the 2026-10-01 change was a
|
||||||
the outside and are not the same problem.
|
cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with the device plainly
|
||||||
|
visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely
|
||||||
|
causes" #2.
|
||||||
|
|
||||||
**4. Is the toggle on?** Named key only — never the whole file:
|
**4. Is the toggle on?** **You cannot answer this from `Preferences.xml` alone.** That mistake is on
|
||||||
|
this page's record: it told readers to treat a missing `HardwareAcceleratedCodecs` as "never
|
||||||
|
enabled", and on 2026-10-02 that key was **absent from all 27 attributes** while hardware
|
||||||
|
transcoding was demonstrably working. Named keys only — never the whole file, it holds
|
||||||
|
`PlexOnlineToken`:
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
||||||
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
|
grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \
|
||||||
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
|
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo NEITHER-KEY'
|
||||||
```
|
```
|
||||||
|
|
||||||
`ABSENT` ⇒ never enabled (the state this page was written in). `="0"` ⇒ explicitly off. `="1"` ⇒ on,
|
| Reading | What it actually means |
|
||||||
and the remaining question is whether it *works* — go to "How to verify".
|
|---|---|
|
||||||
|
| `HardwareDevicePath="…"` **present** | **Positive signal.** Plex writes this when hardware acceleration is configured; it was present on 2026-10-02 alongside a working VAAPI pipeline. |
|
||||||
|
| `HardwareAcceleratedCodecs` **absent** | **Nothing. Neither on nor off.** Measured absent with the feature working (2026-10-02). Do not diagnose from it, and do not write it into the file to "fix" it. |
|
||||||
|
| `HardwareAcceleratedCodecs="0"` | explicitly **off** — the only conclusive reading, and only in the negative. |
|
||||||
|
| `HardwareAcceleratedCodecs="1"` | on. |
|
||||||
|
|
||||||
|
**The log settles it, this file does not.** Go to "How to verify"; the `hardware transcoding:
|
||||||
|
enabled` and `final decoder/final encoder` lines are the verdict.
|
||||||
|
|
||||||
## The one manual step
|
## The one manual step
|
||||||
|
|
||||||
@@ -117,8 +207,11 @@ Plex web UI → **Settings → Transcoder → "Use hardware acceleration when av
|
|||||||
|
|
||||||
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
|
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
|
||||||
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
|
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
|
||||||
recall and not from a forum post. Real neighbouring keys from the same binary, so you can recognise
|
recall and not from a forum post. **But do not expect to see it in `Preferences.xml` after ticking
|
||||||
them and not invent them:
|
the box:** on 2026-10-02, with the toggle on and hardware transcoding working, the key was absent and
|
||||||
|
`HardwareDevicePath` was what had been written. The key exists in the binary; Plex does not
|
||||||
|
necessarily persist it. Real neighbouring keys from the same binary, so you can recognise them and
|
||||||
|
not invent them:
|
||||||
|
|
||||||
| Key | What it is |
|
| Key | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -135,73 +228,223 @@ binary. Recorded explicitly so nobody spends an evening hunting for it or, worse
|
|||||||
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
|
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
|
||||||
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
|
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
|
||||||
|
|
||||||
## How to verify — and why there is no alert
|
## How to verify — the log, not the dashboard
|
||||||
|
|
||||||
Start a playback that **forces a transcode** (a client that cannot direct-play the source, or pick a
|
**Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)`
|
||||||
lower quality explicitly), then read Plex's own **Dashboard → Activity**:
|
was "the *only* proof available, and nothing else will ever tell you". **That was false and it cost
|
||||||
|
the operator real time:** hardware transcoding was fully working — VAAPI decode *and* encode, with
|
||||||
|
zero-copy — while **Dashboard → Activity showed nothing of the sort**. A check that is silent while
|
||||||
|
the feature works does not just fail to prove anything; it sends someone hunting a fault that does
|
||||||
|
not exist. The authoritative check is **Plex's own server log**.
|
||||||
|
|
||||||
- **`Transcode (hw)`** on the session ⇒ hardware transcoding. **This is the proof.**
|
### The primary check — run this one
|
||||||
- plain **`Transcode`** ⇒ software. The feature is not working, whatever the manifest says.
|
|
||||||
|
|
||||||
**That dashboard is the *only* proof available, and nothing else will ever tell you.**
|
```
|
||||||
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
|
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
||||||
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open work
|
grep -aiE "hardware transcoding: (final|using)" \
|
||||||
to "alert on software-transcode fallback": with no metric behind it such a rule would be permanently
|
"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \
|
||||||
inert, and this repo has already been burned at scale by rules that evaluated against nothing while
|
| sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5'
|
||||||
looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence of"). If that
|
```
|
||||||
gap is ever worth closing, the honest order is a real exporter first and a rule second.
|
|
||||||
|
|
||||||
Corroborating, not a substitute — the server log on the config PVC:
|
| Line | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| `hardware transcoding: final decoder: vaapi, final encoder: vaapi` | **PASS.** Hardware in **both** directions. This is the pass condition. |
|
||||||
|
| `hardware transcoding: using zero-copy transcoding` | Best case — frames never round-trip through the CPU. Observed on prod 2026-10-02. |
|
||||||
|
| `hardware transcoding: final decoder: , final encoder: ` (both empty) | It **fell back to software** for that session. |
|
||||||
|
| no matching line at all | No transcode has been attempted since the log rolled — force one (below), then look again. Absence is not a failure. |
|
||||||
|
|
||||||
|
The 2026-10-02 reading on prod, in full, for the shape to recognise:
|
||||||
|
|
||||||
|
```
|
||||||
|
Codecs: hardware transcoding: testing API vaapi for device '/dev/dri/renderD128'
|
||||||
|
(Intel CoffeeLake-S GT2 [UHD Graphics 630])
|
||||||
|
hardware transcoding: using hardware decode accelerator vaapi
|
||||||
|
hardware transcoding: zero-copy support present
|
||||||
|
hardware transcoding: using zero-copy transcoding
|
||||||
|
hardware transcoding: final decoder: vaapi, final encoder: vaapi
|
||||||
|
```
|
||||||
|
|
||||||
|
To make a line appear, start a playback that **forces a transcode**: a client that cannot
|
||||||
|
direct-play the source, or an explicitly lower quality. Direct Play / Direct Stream never reaches
|
||||||
|
the transcoder and logs nothing.
|
||||||
|
|
||||||
|
### Three things to get right about that command
|
||||||
|
|
||||||
|
1. **Always keep the `sed`.** `Plex Media Server.log` carries API URLs containing
|
||||||
|
**`X-Plex-Token=…`**, a live plex.tv credential. Any output that might land in a ticket, a
|
||||||
|
transcript or a chat window must be redacted — CLAUDE.md §6, and a credential has already been
|
||||||
|
leaked into a transcript in this repo exactly this way. The redacting form above is the form to
|
||||||
|
copy; do not "simplify" the pipe out of it.
|
||||||
|
2. **`Plex Transcoder Statistics.log` is the WRONG FILE.** The name is irresistible and someone will
|
||||||
|
reach for it. It holds XML `<Variant .../>` session records — **not** ffmpeg/VAAPI output. There
|
||||||
|
is no decoder/encoder verdict anywhere in it. The file you want is `Plex Media Server.log`.
|
||||||
|
3. `grep -a` is deliberate: the log carries occasional non-UTF-8 bytes, and without `-a` GNU grep
|
||||||
|
reports "binary file matches" and prints nothing — which reads exactly like "no hardware
|
||||||
|
transcoding".
|
||||||
|
|
||||||
|
### Secondary and unreliable: the Plex dashboard
|
||||||
|
|
||||||
|
**Dashboard → Activity** *may* label a transcoding session `Transcode (hw)`. If it does, it agrees
|
||||||
|
with the log and you are done. **If it does not, that proves nothing at all** — measured 2026-10-02:
|
||||||
|
hardware transcoding active and zero-copy, dashboard silent. Never conclude "software transcoding"
|
||||||
|
from the dashboard and never open an investigation on its say-so. Kept here only because it is the
|
||||||
|
fastest glance available; it is a convenience, **not evidence**.
|
||||||
|
|
||||||
|
### Process-level, while a transcode is in flight: the transcoder's argv
|
||||||
|
|
||||||
|
This shows Plex *requesting* hardware, as distinct from reporting it afterwards. Get a shell in the
|
||||||
|
pod (if `-it` has no TTY under `dev.sh run`, wrap each line in `sh -c "…"` instead):
|
||||||
|
|
||||||
```
|
```
|
||||||
infra/devcontainer/dev.sh run bash -c \
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh'
|
||||||
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
|
|
||||||
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions.
|
then, inside the pod:
|
||||||
Absence of a match is **not** evidence of software transcoding; a VA-API device open is good evidence
|
|
||||||
of hardware. The dashboard remains the verdict.
|
```
|
||||||
|
ps -eo pid,user,comm | grep -i transcoder
|
||||||
|
tr '\0' ' ' < /proc/<transcoder-pid>/cmdline | grep -oE '(-hwaccel[a-z_]*|vaapi|renderD[0-9]+)'
|
||||||
|
```
|
||||||
|
|
||||||
|
`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128` present ⇒ Plex is asking for hardware. Keep the
|
||||||
|
`grep -oE`: the raw argv contains media paths and session identifiers you do not want in a
|
||||||
|
transcript. This only works while a session is actually transcoding.
|
||||||
|
|
||||||
|
### Documented false lead: scanning `/proc/*/fd` for a holder of `renderD128`
|
||||||
|
|
||||||
|
**It returns nothing even when hardware transcoding is working.** Measured 2026-10-02: a
|
||||||
|
`/proc/*/fd` sweep inside the pod found **no** process holding `/dev/dri/renderD128`, while the log
|
||||||
|
showed `final decoder: vaapi, final encoder: vaapi` for sessions either side of the scan. Transcode
|
||||||
|
sessions are **short** and the scan landed in a gap between two of them. An empty fd scan is a
|
||||||
|
**timing artefact, not a finding** — do not report it as evidence of software transcoding, and do
|
||||||
|
not escalate on it. Use the argv check above (which has the same timing constraint but tells you
|
||||||
|
something when it does catch a session), or just read the log, which is retrospective and therefore
|
||||||
|
does not care about timing.
|
||||||
|
|
||||||
|
### Why there is no alert — this half was always right
|
||||||
|
|
||||||
|
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
|
||||||
|
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open
|
||||||
|
work to "alert on software-transcode fallback": with no metric behind it such a rule would be
|
||||||
|
permanently inert, and this repo has already been burned at scale by rules that evaluated against
|
||||||
|
nothing while looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence
|
||||||
|
of"). If that gap is ever worth closing, the honest order is a real exporter first and a rule
|
||||||
|
second.
|
||||||
|
|
||||||
## Likely causes, most likely first
|
## Likely causes, most likely first
|
||||||
|
|
||||||
When the dashboard still says plain `Transcode`:
|
When the **log** says it fell back to software — `final decoder: , final encoder: ` with both
|
||||||
|
fields empty. (Not when the dashboard is quiet: the dashboard's silence means nothing, see "How to
|
||||||
|
verify".)
|
||||||
|
|
||||||
1. **The toggle is off.** Overwhelmingly the most likely answer, and the reason this page exists.
|
1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was
|
||||||
First-command #4 returns `ABSENT` or `="0"`. Remedy: the manual step above. Check this **before**
|
ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset
|
||||||
investigating anything technical.
|
it. Remedy: the manual step above. **Do not diagnose this from a missing
|
||||||
|
`HardwareAcceleratedCodecs`** — see first-command #4; only `="0"` is conclusive, and the log line
|
||||||
|
`hardware transcoding: enabled` is the real signal that the toggle took.
|
||||||
|
|
||||||
2. **containerd's device cgroup is refusing the `open()` — UNVERIFIED, and the largest remaining
|
2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
|
||||||
technical risk in this feature.** Whether a non-privileged pod may `open()` a bind-mounted
|
first-command #3 shows `renderD128` present and correctly owned, and the open returns
|
||||||
character device on this k3s/containerd build **has not been established**; it can only be settled
|
`RW-OPEN-DENIED` / `Operation not permitted`. This is `EPERM` from the **device cgroup**, not
|
||||||
by deploying and trying. The container inits as uid 0, which settles *file* permissions, but
|
`EACCES` from file permissions. **This is no longer a design risk — it is the measured reason the
|
||||||
**uid 0 does not bypass the cgroup device gate.** Symptom: step 2 shows `renderD128` present and
|
design is what it is** (2026-10-01; see "Why a resource request and not a `/dev/dri` hostPath").
|
||||||
correctly owned, step 3 returns `RW-OPEN-DENIED`.
|
If it recurs *under the current mechanism*, the plugin is the suspect, not the manifest: confirm
|
||||||
**The documented fallback is the Intel Device Plugin for Kubernetes** (`gpu.intel.com/i915` as a
|
the pod really did consume the resource
|
||||||
resource request, no hostPath). Because the ServiceDefinition field expresses *intent* rather than
|
|
||||||
a device path, that migration is a **`catalog/render.py` change plus a re-render — with no
|
|
||||||
ServiceDefinition edit anywhere**; `catalog/plex.yaml` keeps saying exactly what it says today.
|
|
||||||
**`privileged: true` is not an available escape hatch:** Kyverno `restrict-host-namespaces` is in
|
|
||||||
**Enforce** mode and rejects it in app namespaces. Do not reach for it.
|
|
||||||
|
|
||||||
3. **The pod landed on a node without the device.** Step 2 shows an empty `/dev/dri`. All three prod
|
```
|
||||||
nodes carried `renderD128` with `i915` bound when this was measured (2026-09-30), so this means a
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
node changed — a rebuild, a BIOS reset, or a driver regression. `DirectoryOrCreate` is why the app
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
|
||||||
tier still came up; the cost is that the loss is silent. **Fix the node, do not tighten the mount:**
|
-o jsonpath="{.items[0].spec.containers[0].resources}"; echo'
|
||||||
`Directory` would stall `apps-wave-0` (`wait: true`, with `apps-wave-1` depending on it) for a
|
```
|
||||||
feature that is not load-bearing.
|
|
||||||
|
and then check the plugin pod on Plex's node (first-command #1). **Do not reach for
|
||||||
|
`privileged: true`** — Kyverno `restrict-host-namespaces` is Enforce and rejects it, and uid 0
|
||||||
|
failed the same open in 2026-09-30's measurement anyway. **Do not re-add a `/dev/dri` hostPath**;
|
||||||
|
that is the mechanism this replaced, and it was measured not to work.
|
||||||
|
|
||||||
|
3. **The pod landed on a node whose GPU went away.** Under the current mechanism this does **not**
|
||||||
|
present as an empty `/dev/dri` — the node simply stops advertising `gpu.intel.com/i915` and Plex
|
||||||
|
either never schedules there or gets evicted off. So this cause now shows up as **`Pending`**, not
|
||||||
|
as silent software transcoding: see "Plex will not schedule". All three prod nodes carried
|
||||||
|
`renderD128` with `i915` bound when measured (2026-09-30) and all three advertised the resource on
|
||||||
|
2026-10-01, so this means a node changed — a rebuild, a BIOS reset, or a driver regression.
|
||||||
|
**Fix the node.**
|
||||||
|
|
||||||
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
||||||
mechanism this feature leans on with no commit in this repo.
|
mechanism this feature leans on with no commit in this repo.
|
||||||
|
|
||||||
5. **The session was never transcoding.** Direct Play / Direct Stream never touches the transcoder, so
|
5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder,
|
||||||
`Transcode (hw)` cannot appear. Confirm the session actually says `Transcode` before treating it as
|
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
|
||||||
a hardware failure.
|
failure — confirm a transcode actually happened before treating anything as a hardware fault.
|
||||||
|
|
||||||
6. **The source needs a codec Quick Sync on this generation cannot handle** — e.g. AV1 decode, which
|
6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed
|
||||||
Coffee Lake's UHD 630 has no engine for. Plex falls back to software *per stream*. Symptom: some
|
diagnosis.** The symptom that points here is fallback on **particular titles** while other
|
||||||
sessions show `(hw)` and others do not. Not a fault.
|
sessions are hardware. Measured on prod 2026-10-02, *between* two fully hardware sessions, one
|
||||||
|
session logged:
|
||||||
|
|
||||||
|
```
|
||||||
|
hardware transcoding: enabled, but no hardware decode accelerator found
|
||||||
|
hardware transcoding: final decoder: , final encoder:
|
||||||
|
```
|
||||||
|
|
||||||
|
The platform was demonstrably working either side of it, so the likely explanation is the **file**,
|
||||||
|
not the cluster: UHD 630's media engine decodes H.264, HEVC 8-bit and 10-bit, and VP9 — but **not
|
||||||
|
AV1**, and not 10-bit 4:2:2 HEVC. **This was NOT verified against that particular title.** It is
|
||||||
|
inferred from the hardware's capability set and is written here as the lead to check first, not as
|
||||||
|
a finding. So: fallback on *all* files is a platform problem (causes 1-4); fallback on *some*
|
||||||
|
files, check the source codec before suspecting the device, and expect occasional legitimate
|
||||||
|
per-title fallbacks to be normal rather than a fault.
|
||||||
|
|
||||||
|
## Plex will not schedule
|
||||||
|
|
||||||
|
**Symptom:** the Plex pod sits `Pending`, and `apps-wave-0` does not go Ready, which holds
|
||||||
|
`apps-wave-1` behind it. This is the cost of the 2026-10-01 mechanism and the section you want at
|
||||||
|
2am.
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex describe pod -l app.kubernetes.io/name=plex | tail -20'
|
||||||
|
```
|
||||||
|
|
||||||
|
`0/3 nodes are available: 3 Insufficient gpu.intel.com/i915` (or similar) confirms it: **nothing is
|
||||||
|
advertising the resource Plex requires**. Plex's schedulability is coupled to the `intel-gpu-plugin`
|
||||||
|
DaemonSet, deliberately and unavoidably — the plugin is also the only thing that produces the
|
||||||
|
device-cgroup allow rule, so there is no variant of this feature that degrades gracefully.
|
||||||
|
|
||||||
|
**Remedy A — fix the plugin (preferred).** Run first-command #1. If the DaemonSet is not 3/3, or the
|
||||||
|
nodes show `i915=ABSENT`, the fault is in `platform/intel-gpu-plugin/` or on the node, and that is
|
||||||
|
where to fix it. Check its Flux Kustomization reconciled:
|
||||||
|
|
||||||
|
```
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n flux-system get kustomization intel-gpu-plugin'
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin that is merely **not yet reconciled** resolves itself; one that is failing does not.
|
||||||
|
|
||||||
|
**Remedy B — the escape hatch: give up hardware transcoding and unblock the tier.** One field, no
|
||||||
|
code change. In a branch (never on `main`, never `kubectl edit` — Flux reconciles `main` and would
|
||||||
|
revert you):
|
||||||
|
|
||||||
|
1. delete these two lines from `catalog/plex.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hardware:
|
||||||
|
gpu: intel-quicksync
|
||||||
|
```
|
||||||
|
|
||||||
|
2. re-render the golden:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation
|
||||||
|
```
|
||||||
|
|
||||||
|
The only change must be `apps/wave-0/plex.yaml`, losing its `resources` block.
|
||||||
|
3. gate, PR, merge. Plex schedules anywhere and **software-transcodes**; the app tier unblocks.
|
||||||
|
|
||||||
|
Re-adding the field later is the same edit in reverse. Nothing about the Plex-side toggle or the
|
||||||
|
`/config` PVC is affected either way — do **not** delete the PVC as part of this.
|
||||||
|
|
||||||
## The floating-image caveat
|
## The floating-image caveat
|
||||||
|
|
||||||
@@ -229,21 +472,37 @@ infra/devcontainer/dev.sh run bash -c \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
|
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
|
||||||
described here. Gone or renamed ⇒ stop and re-measure. In particular the "no `supplementalGroups`
|
described here: the hook reads the injected device's **own** ownership, which under the device plugin
|
||||||
needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that
|
is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). Gone or renamed ⇒ stop and
|
||||||
hook exists.
|
re-measure. In particular the "no `supplementalGroups` needed" conclusion in `catalog/plex.yaml` and
|
||||||
|
`schemas/renderer-contract.md` is only true while that hook exists.
|
||||||
|
|
||||||
## When to escalate or stop
|
## When to escalate or stop
|
||||||
|
|
||||||
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
|
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
|
||||||
**inert**, and it would mislead the next person. Measured in-container 2026-09-30: the image's
|
**inert**, and it would mislead the next person. The image's `cont-init.d` hook stats each
|
||||||
`cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into its
|
`/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside
|
||||||
group *inside* the container, and the service then execs via `s6-setuidgid plex`, which **replaces**
|
the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group
|
||||||
the process group list from `/etc/group` rather than appending — `Plex Media Server` runs with
|
list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore
|
||||||
`Groups: 100 1000`, root's gid `0` (which PID 1 does hold) gone. Anything Kubernetes supplies is
|
|
||||||
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
||||||
|
- **GID `992` is a hostPath-era number — do not expect it, and do not read the root group as a
|
||||||
|
defect.** Under the device plugin the injected `/dev/dri/renderD128` arrives **`root:root`**, so
|
||||||
|
the hook resolves GID **`0`** and adds `plex` to the **root group**. Measured 2026-10-02:
|
||||||
|
`Plex Media Server` runs with `Groups: 0 100 1000`, and `id plex` reports
|
||||||
|
`groups=1000(plex),0(root),100(users)`. Under the old hostPath the device was `root:render`
|
||||||
|
(GID 992 on these nodes) and the reading was `Groups: 44 100 992 1000`. **992 is now irrelevant**:
|
||||||
|
if any page, comment or older handoff tells you to expect it, that text is stale and the system
|
||||||
|
you are looking at is working. The root-group membership is an inherent property of the plugin's
|
||||||
|
`DeviceSpec` — it injects the host device with its host ownership — **not** a defect, and **not** a
|
||||||
|
reason to reintroduce `supplementalGroups`, which would still be a no-op for the reason in the
|
||||||
|
bullet above.
|
||||||
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
||||||
reject it. If the cgroup gate is the blocker, the answer is the Intel device plugin (cause #2).
|
reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement,
|
||||||
|
because root does not bypass the cgroup device gate.
|
||||||
|
- **Do not re-add a `/dev/dri` hostPath, and do not "simplify" the resource request into one.** That
|
||||||
|
was the original mechanism and it was measured non-functional on 2026-10-01 (device-cgroup `EPERM`
|
||||||
|
with the device plainly visible). The device plugin's `DeviceSpec{… Permissions: "rw"}` is what
|
||||||
|
grants access; a volume cannot.
|
||||||
- **Do not `kubectl edit`/`patch` the Deployment to add or change the device mount.** Flux reconciles
|
- **Do not `kubectl edit`/`patch` the Deployment to add or change the device mount.** Flux reconciles
|
||||||
`main`; a hand-applied change is drift that the next reconcile reverts or, worse, adopts. Mutating
|
`main`; a hand-applied change is drift that the next reconcile reverts or, worse, adopts. Mutating
|
||||||
`kubectl` and `flux reconcile` are **operator** actions, and the durable change is a git change
|
`kubectl` and `flux reconcile` are **operator** actions, and the durable change is a git change
|
||||||
@@ -253,9 +512,10 @@ hook exists.
|
|||||||
- **Do not delete the `plex-config` PVC to "start clean".** It is the library database, metadata and
|
- **Do not delete the `plex-config` PVC to "start clean".** It is the library database, metadata and
|
||||||
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
|
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
|
||||||
lose the DB.
|
lose the DB.
|
||||||
- **Stop and get the operator** if the device cannot be opened from the pod (cause #2 confirmed). The
|
- **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2
|
||||||
next step is a platform change — the Intel device plugin — with its own PR, not more poking at this
|
confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02
|
||||||
workload.
|
(`RW-OPEN-OK` as uid 1000 via `s6-setuidgid plex`), so a failure now is a regression. The next step is a platform change in
|
||||||
|
`platform/intel-gpu-plugin/`, with its own PR, not more poking at this workload.
|
||||||
|
|
||||||
## Known gaps, deliberately not fixed here
|
## Known gaps, deliberately not fixed here
|
||||||
|
|
||||||
@@ -277,7 +537,25 @@ Recorded so they are findable, and so nobody assumes they were overlooked:
|
|||||||
|
|
||||||
---
|
---
|
||||||
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
|
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
|
||||||
The node measurements (all three prod nodes) and the in-container image measurements cited here were
|
|
||||||
taken **2026-09-30** against `plexinc/pms-docker` digest `sha256:e0ab27395614…`
|
**Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From
|
||||||
(`plexmediaserver 1.43.4.10903`). Hardware transcoding had **not** been observed working at the time
|
`Plex Media Server.log` in the running pod: API `vaapi` on `/dev/dri/renderD128`
|
||||||
of writing, and the Plex-side toggle was still unset.
|
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
||||||
|
`zero-copy support present`, `using zero-copy transcoding`, and
|
||||||
|
`final decoder: vaapi, final encoder: vaapi`. Also measured in the same pod: `RW-OPEN-OK` opening
|
||||||
|
`/dev/dri/renderD128` as uid 1000 via `s6-setuidgid plex`; the pod spec carrying
|
||||||
|
`{"limits":{"gpu.intel.com/i915":"1"},"requests":{"gpu.intel.com/i915":"1"}}`; `Groups: 0 100 1000`
|
||||||
|
and `id plex` → `groups=1000(plex),0(root),100(users)`; and `HardwareDevicePath` present in
|
||||||
|
`Preferences.xml` while `HardwareAcceleratedCodecs` was absent from all 27 attributes.
|
||||||
|
|
||||||
|
Earlier readings, kept for provenance and **superseded** where they conflict: the node measurements
|
||||||
|
(all three prod nodes) and the in-container image measurements taken **2026-09-30** against
|
||||||
|
`plexinc/pms-docker` digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`); the hostPath
|
||||||
|
`EPERM` open results and the `Groups: 44 100 992 1000` / GID-992 group reading, **2026-10-01, under
|
||||||
|
the hostPath mechanism that no longer exists**; and the `gpu.intel.com/i915: 1` allocatable and
|
||||||
|
`intel-gpu-plugin` DaemonSet readings, **2026-10-01**.
|
||||||
|
|
||||||
|
What this page still does **not** establish: that any particular title's software fallback was caused
|
||||||
|
by its source codec (cause 6 is an unverified hypothesis), and that Plex's dashboard *ever* labels a
|
||||||
|
session `Transcode (hw)` on this deployment — it has not been observed doing so, which is exactly why
|
||||||
|
the log is now the primary check.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# plex-remote-access — getting Plex reachable from outside the LAN, and proving it
|
||||||
|
|
||||||
|
**Status: the in-cluster half is shipped; the last mile is TWO MANUAL STEPS and remote access does
|
||||||
|
NOT work until both are done.** Neither can live in this repo.
|
||||||
|
**Alert:** none, and none is possible from in-cluster signals — see "Why no alert" below. This is an
|
||||||
|
**operator procedure**, not an incident page.
|
||||||
|
**Files:** `apps/wave-0/plex-remote.yaml` (the tcp/32400 LoadBalancer + its CiliumNetworkPolicy),
|
||||||
|
`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml` (`PLEX_L4_VIP`),
|
||||||
|
`catalog/render.py` (the `EXTRA_UNITS` entry that places the unit), `catalog/plex.yaml` (Plex's own
|
||||||
|
env — note `ADVERTISE_IP` is deliberately NOT set; see "Why not the Gateway URL").
|
||||||
|
**Related:** `runbooks/plex-quicksync.md` and `runbooks/plex-claim.md` — the other two Plex
|
||||||
|
procedures with a manual last mile.
|
||||||
|
|
||||||
|
## What was actually broken (measured 2026-10-03)
|
||||||
|
|
||||||
|
Plex was serving correctly and was reachable on the LAN. Two independent things stopped remote
|
||||||
|
clients:
|
||||||
|
|
||||||
|
1. **Plex advertised no remote connection at all.** In the live `Preferences.xml`:
|
||||||
|
`customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while
|
||||||
|
`PublishServerOnPlexOnlineKey="1"`. The server was trying to publish itself to plex.tv and had
|
||||||
|
nothing reachable to publish. Plex's automatic path (UPnP/NAT-PMP) cannot work here: the process
|
||||||
|
lives in a pod on the Cilium overlay (`10.0.2.136`), it cannot speak UPnP to the SOHO router, and
|
||||||
|
a mapping to the pod IP would be meaningless if it could.
|
||||||
|
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` → connection refused.
|
||||||
|
`136.51.115.252:443` → a **self-signed router certificate** (`C=ZZ, CN=%D`), not the Gateway's
|
||||||
|
Let's Encrypt wildcard.
|
||||||
|
|
||||||
|
The Service was `ClusterIP` only, so port 32400 had no path off-cluster by any route.
|
||||||
|
|
||||||
|
**Caveat on that second measurement, because it is easy to over-read:** it was taken from *inside*
|
||||||
|
the LAN, so strictly it proves there is no NAT hairpin (reflection). It does not prove outside-in
|
||||||
|
failure. But a router certificate answering 443 means the router's own management UI is bound to the
|
||||||
|
WAN port, and that *would* shadow a port-forward for genuine outside traffic too. **Verify from a
|
||||||
|
real outside vantage point** (phone on cellular, or an external port checker) before concluding
|
||||||
|
either way — this repo has no such vantage point and must not pretend otherwise.
|
||||||
|
|
||||||
|
## Step 1 — forward WAN tcp/32400 on the router (operator, off-repo)
|
||||||
|
|
||||||
|
WAN tcp/32400 -> 192.168.2.24 (${PLEX_L4_VIP}) port 32400
|
||||||
|
|
||||||
|
`192.168.2.24` is a pinned Cilium LB VIP, ARP-announced on the flat LAN by the existing
|
||||||
|
`CiliumL2AnnouncementPolicy default-l2`. It is **pinned on purpose**: this address is typed into the
|
||||||
|
router by hand, so it must never move on a reschedule.
|
||||||
|
|
||||||
|
While in the router, **check what else is bound to the WAN interface.** If remote management is
|
||||||
|
listening on 443 (see the certificate measurement above), move it or disable it — otherwise the
|
||||||
|
Gateway's 80/443 forward is shadowed and the Plex *web UI* over the internet stays broken even
|
||||||
|
though remote *streaming* works. These are two separate paths and they fail independently.
|
||||||
|
|
||||||
|
Confirm the VIP is actually answering on the LAN first, so a failure later is unambiguous:
|
||||||
|
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex get svc plex-l4-tcp -o wide'
|
||||||
|
|
||||||
|
`EXTERNAL-IP` must read `192.168.2.24`. If it is `<pending>`, LB-IPAM could not assign — the address
|
||||||
|
is outside the pool or already taken; see `clusters/prod/kustomization.yaml` for the pool accounting.
|
||||||
|
|
||||||
|
**`192.168.2.24` was never ARP-probed off-cluster.** Only the in-cluster pool accounting was checked
|
||||||
|
(IPsTotal 11 / IPsUsed 4 / IPsAvailable 7 on 2026-10-03). If the VIP behaves oddly, probe it from a
|
||||||
|
LAN host — `arping 192.168.2.24` — the way `.23` was probed before it was claimed.
|
||||||
|
|
||||||
|
## Step 2 — tick "manually specify public port" in Plex (operator, UI only)
|
||||||
|
|
||||||
|
**There is no GitOps path to this and no environment variable for it.** Measured in the running
|
||||||
|
image on 2026-10-03: a grep of the entire init surface (`/etc/cont-init.d`, `/plex-common.sh`,
|
||||||
|
`/usr/local/bin`) for `ManualPortMapping` and `PLEX_PREFERENCE` returned **NONE**. The image writes
|
||||||
|
only six preferences, and this is not one of them. It is the same class of manual last mile as the
|
||||||
|
`HardwareAcceleratedCodecs` toggle in `runbooks/plex-quicksync.md`.
|
||||||
|
|
||||||
|
In the Plex web UI (`https://plex.home.keithjackson.dev`, on the LAN):
|
||||||
|
|
||||||
|
**Settings → Remote Access**
|
||||||
|
- Ensure **Enable Remote Access** is on. (`PublishServerOnPlexOnlineKey` was already `1`.)
|
||||||
|
- Tick **Manually specify public port** and set it to **32400**.
|
||||||
|
- **Apply**, then let the panel settle. It should report *"Fully accessible outside your network"*.
|
||||||
|
|
||||||
|
Without this tick Plex waits for a NAT mapping it can never obtain and publishes no remote
|
||||||
|
connection, so the port-forward in step 1 sits there unused.
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
Do these in order; each one isolates a different half.
|
||||||
|
|
||||||
|
**1. The port is open from outside.** From a phone on cellular, or any off-LAN host:
|
||||||
|
|
||||||
|
curl -sk -o /dev/null -w '%{http_code}\n' https://136.51.115.252:32400/identity
|
||||||
|
|
||||||
|
`200` means the forward and the policy are both good. A hang or refusal means step 1.
|
||||||
|
|
||||||
|
**2. Plex itself believes it is reachable.** Settings → Remote Access must say *"Fully accessible
|
||||||
|
outside your network"*. If it says *"not available outside your network"* while test 1 passes, step 2
|
||||||
|
was not applied.
|
||||||
|
|
||||||
|
**3. plex.tv is handing out a remote connection.** The decisive test, because it is what clients
|
||||||
|
actually consume: sign in to [app.plex.tv](https://app.plex.tv) from an off-LAN network and confirm
|
||||||
|
the server appears and plays. A client finds this server through plex.tv's connection list, never
|
||||||
|
through DNS.
|
||||||
|
|
||||||
|
**4. The policy is admitting the traffic.** If test 1 fails *after* the forward is confirmed, the
|
||||||
|
CiliumNetworkPolicy is the next suspect:
|
||||||
|
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex get cnp plex-allow-remote-32400'
|
||||||
|
|
||||||
|
See `runbooks/denied-flows.md` for reading actual drops.
|
||||||
|
|
||||||
|
## Why no alert
|
||||||
|
|
||||||
|
Every in-cluster signal is green whether or not remote access works. The Service has endpoints, the
|
||||||
|
pod is `Running`, the policy is applied, and `plex-down` watches
|
||||||
|
`kube_deployment_status_replicas_available` — none of which can see a router with a missing
|
||||||
|
port-forward or an unticked checkbox. An alert would need an off-LAN prober, which this cluster does
|
||||||
|
not have. **Test 3 above is the only honest check, and it is manual.**
|
||||||
|
|
||||||
|
## Why not the Gateway URL (`ADVERTISE_IP`)
|
||||||
|
|
||||||
|
The alternative was to leave Plex on `ClusterIP` and set
|
||||||
|
`ADVERTISE_IP: https://plex.home.keithjackson.dev:443`, which writes Plex's `customConnections`
|
||||||
|
preference and would have put the server on plex.tv's list via the existing L7 Gateway — no new WAN
|
||||||
|
hole. That mechanism was verified to work on an already-claimed server (the `ADVERTISE_IP` block at
|
||||||
|
line 126 of the image's `40-plex-first-run` sits **outside** the claim guard, and `/.firstRunComplete`
|
||||||
|
lives on the overlay FS, not the `/config` PVC, so it re-runs every pod start), and the Gateway path
|
||||||
|
itself was measured serving Plex correctly: `HTTP 200`, `claimed="1"`, with `401` on every protected
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
**The operator chose the native path anyway (2026-10-03), and the reasons are real:** Plex terminates
|
||||||
|
its own TLS with its `*.plex.direct` certificate, and video streams bypass the Envoy L7 hop instead
|
||||||
|
of being proxied through it. The cost is a new unfiltered WAN port — acceptable because Plex
|
||||||
|
authenticates every caller itself. `ADVERTISE_IP` therefore stays unset in `catalog/plex.yaml`, and
|
||||||
|
the deferral note there is still accurate.
|
||||||
|
|
||||||
|
## Known wart: remote clients are classified as LAN
|
||||||
|
|
||||||
|
`apps/wave-0/plex-remote.yaml` uses `externalTrafficPolicy: Cluster`, which SNATs the client to the
|
||||||
|
ingress node's address (`192.168.2.1x`). That falls inside the `192.168.0.0/16` entry of
|
||||||
|
`ALLOWED_NETWORKS`, so **Plex treats every remote stream as local.** Measured on the Gateway path,
|
||||||
|
which SNATs the same way: Plex logged `[10.0.2.214:36040 (Allowed Network (WAN))]`.
|
||||||
|
|
||||||
|
This is a **metering** effect, not a security one — authorization is unchanged, as the 401s above
|
||||||
|
show — but Plex's remote-quality and bandwidth limits will not bind on these streams.
|
||||||
|
|
||||||
|
`Local` would preserve the client IP and fix it. **Do not switch to it.** Cilium's L2 announcer
|
||||||
|
elects one lease holder per Service and does *not* follow the backend pod, so the VIP can be
|
||||||
|
announced by a node with no Plex pod, black-holing the port while everything reports healthy. That
|
||||||
|
exact change cost this LAN an 8-day Minecraft outage in September 2026 — the full write-up is at the
|
||||||
|
top of `platform/crafty/service-l4.yaml`. Plex is a single-replica `Recreate` Deployment, so the
|
||||||
|
node mismatch is the normal case here, not a rare one.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# vpn-remote-access — standing up the WireGuard remote-access VPN, and proving it
|
||||||
|
|
||||||
|
**Status: the in-cluster half is shipped; the VPN is INERT until three manual steps are done.**
|
||||||
|
All three are off-repo by necessity — a kernel module, a private key, and a router port-forward.
|
||||||
|
**Alert:** pod-down is covered generically (`KubePodNotReady` / `KubeDeploymentReplicasMismatch`).
|
||||||
|
There is **no peer-level alerting** — see "Why there is no connection alert". This is an **operator
|
||||||
|
procedure**, not an incident page.
|
||||||
|
**Files:** `platform/vpn-ingress/` (namespace, peer register, DaemonSet, baseline policy),
|
||||||
|
`clusters/base/vpn-ingress.yaml` (its Flux tier), `clusters/base/cluster-vars.yaml`
|
||||||
|
(`WG_CLIENT_CIDR`), `infra/ansible/roles/node_baseline/defaults/main.yml` (the `wireguard` module),
|
||||||
|
`infra/vpn-ingress/new-peer.sh` (device enrolment).
|
||||||
|
**Design rationale:** `platform/vpn-ingress/README.md` — read "Why `hostNetwork` is required"
|
||||||
|
before changing anything about how this is wired.
|
||||||
|
**Not to be confused with** `vpn-down` / `vpn-egress-stale`, which are about `platform/vpn-egress`
|
||||||
|
(pods *out* through PIA). This page is remote clients *in*.
|
||||||
|
|
||||||
|
## What this gives you
|
||||||
|
|
||||||
|
A device — Android, Linux, Windows, macOS, iOS — dials in over **udp/51820** from anywhere and
|
||||||
|
behaves like a machine on the LAN: full reach to `192.168.2.0/24`, the Gateway's web UIs, PiHole
|
||||||
|
DNS, and internet egress out the house WAN.
|
||||||
|
|
||||||
|
Authorisation is one thing only: the device's **public key is in
|
||||||
|
`platform/vpn-ingress/peers.yaml`**. An unlisted key gets no reply at all.
|
||||||
|
|
||||||
|
> **An enrolled device is as privileged as a machine sitting in the house** — including the three
|
||||||
|
> k3s nodes (`192.168.2.10/.11/.12`: apiserver 6443, kubelet 10250, SSH 22). That is what "reach
|
||||||
|
> the entire local network" means. Revoke a lost device promptly: delete its stanza, PR, merge.
|
||||||
|
|
||||||
|
## Step 1 — load the `wireguard` module on every node (once)
|
||||||
|
|
||||||
|
The DaemonSet creates its `wg0` link in the host netns with `CAP_NET_ADMIN` only; it is
|
||||||
|
deliberately **not** granted `CAP_SYS_MODULE`, so it cannot autoload the module. Without it
|
||||||
|
`wg-quick` fails outright and the pod CrashLoops.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# operator action (Ansible is not an agent action). Ansible lives ONLY in the pinned toolbox,
|
||||||
|
# and ansible.cfg is only picked up from infra/ansible, so cd there inside the container.
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
|
||||||
|
```
|
||||||
|
|
||||||
|
Three details in that command are load-bearing, not style:
|
||||||
|
|
||||||
|
- **`-e k3s_hosts=k3s_prod`.** `node-baseline.yml` targets `k3s_servers` by default, which is the
|
||||||
|
**dev** group — and dev was decommissioned, so an unqualified run does nothing useful. This is
|
||||||
|
the targeting pattern `infra/ansible/README.md` "Targeting prod" documents for every playbook.
|
||||||
|
- **`--tags system`, and never an untagged run.** The two kernel-module tasks carry the `system`
|
||||||
|
tag. An untagged run would also execute the `disks` tasks, which assert on block-device variables
|
||||||
|
prod has deliberately left as placeholders (`infra/PROD-PROVISIONING.md` §2) — the playbook's own
|
||||||
|
header warns about this. `--tags system` is broader than the modules alone (it also re-asserts
|
||||||
|
sysctls, iscsid, qemu-guest-agent and the swap masking) but every one of those is idempotent and
|
||||||
|
already in its desired state on a live node.
|
||||||
|
- **It is a playbook, not a wrapper script.** There is no `site.sh` in this repo; `playbooks/site.yml`
|
||||||
|
is the full bring-up chain and is the wrong tool here.
|
||||||
|
|
||||||
|
Verify on each node — the module is in-tree since kernel 5.6, so nothing is installed, only loaded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# expected: a `wireguard` line, and the modules-load.d file naming it for the next boot
|
||||||
|
lsmod | grep '^wireguard'
|
||||||
|
grep -w wireguard /etc/modules-load.d/k3s-cilium-longhorn.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2 — create the server key (once)
|
||||||
|
|
||||||
|
This is the **only** secret in the component, and the one thing that must never be generated in an
|
||||||
|
agent session or pasted into a transcript. Generate it on your own machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
|
||||||
|
umask 077
|
||||||
|
wg genkey > ~/.config/wireguard-home/server.key # keep this; see "Back it up" below
|
||||||
|
```
|
||||||
|
|
||||||
|
Load it into the cluster. **Do not echo it, do not pass it on an interactive command line that
|
||||||
|
lands in shell history, and never print the resulting Secret's `.data`** (CLAUDE.md §6):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress create secret generic wg-server-key \
|
||||||
|
--from-file=privatekey=/work/.cache/server.key'
|
||||||
|
```
|
||||||
|
|
||||||
|
…where `/work/.cache/server.key` is a path you copied the key to (`.cache/` is not tracked; create
|
||||||
|
it if absent). Delete that copy as soon as the Secret exists: `shred -u .cache/server.key`.
|
||||||
|
|
||||||
|
> **KEEP THE `.key` SUFFIX.** That filename is what keeps the key out of git: `.gitignore` line 11
|
||||||
|
> is `*.key`, and it is the **only** rule covering this path — `.cache/` itself is NOT ignored
|
||||||
|
> (`git check-ignore -v .cache/serverkey.txt` matches nothing). So a copy named `server.pem`,
|
||||||
|
> `wg-server` or `privatekey` sitting in that directory is **committable**, and the gate's
|
||||||
|
> `no-plaintext-secrets` stage does not know a WireGuard private key when it sees one. Verify before
|
||||||
|
> you trust it: `git check-ignore -v .cache/<your-filename>` must print a rule, and
|
||||||
|
> `git status --short` must not list the file.
|
||||||
|
|
||||||
|
Confirm it exists **by key name only**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress get secret wg-server-key -o jsonpath="{.data}" | tr "," "\n" | cut -d: -f1'
|
||||||
|
# expected: {"privatekey -- a NAME. If you see base64 payload, you printed the key. Rotate it.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why out of band and not SOPS.** An agent must not mint a long-lived private key, and a key that
|
||||||
|
passed through a session transcript would have to be treated as compromised. The trade-off is that
|
||||||
|
this Secret is **not reconstructible from git** — so:
|
||||||
|
|
||||||
|
**Back it up.** Keep `server.key` in your password manager. Velero's `daily` schedule excludes only
|
||||||
|
`monitoring` and `unifi` (`platform/velero/helmrelease.yaml`), so this namespace *is* captured
|
||||||
|
off-cluster to R2 as well. Restoring this one key is what makes every already-enrolled device work
|
||||||
|
again after a cluster rebuild — **losing it means re-enrolling every device.**
|
||||||
|
|
||||||
|
## Step 3 — forward udp/51820 on the router (once)
|
||||||
|
|
||||||
|
On the SOHO router at `192.168.2.1`, forward **WAN udp/51820** to **one node address**, e.g.
|
||||||
|
`192.168.2.10:51820`. This is hand-made and off-repo; it is the half CI can never check.
|
||||||
|
|
||||||
|
Pick any node — all three run an identical listener. Note which one you chose here:
|
||||||
|
|
||||||
|
```
|
||||||
|
# WAN udp/51820 -> 192.168.2.__ (k3s-prod-_) set <date>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Node loss = failover is this one line.** Re-point the forward at another node address; every
|
||||||
|
device keeps working with the same profile, because all three nodes answer the same keys.
|
||||||
|
|
||||||
|
Do **not** also expose this on tcp, and do not change the port hoping for obscurity — WireGuard is
|
||||||
|
already silent to unauthenticated probes (README, "Why this is safe to expose").
|
||||||
|
|
||||||
|
## Step 4 — confirm the listener is healthy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress get ds wireguard -o wide'
|
||||||
|
# expected: DESIRED 3 CURRENT 3 READY 3
|
||||||
|
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
|
||||||
|
# expected: `interface: wg0`, `listening port: 51820`, and (before step 5) NO peers.
|
||||||
|
```
|
||||||
|
|
||||||
|
A zero-peer interface that is Ready is the **correct** state before any device is enrolled: it
|
||||||
|
listens, and refuses every handshake because no key is trusted.
|
||||||
|
|
||||||
|
The readiness/liveness probe is `wg show wg0`, and that is load-bearing rather than cosmetic: a
|
||||||
|
port probe cannot distinguish "wg0 never came up" from "working", because WireGuard is silent to
|
||||||
|
unauthenticated traffic either way. If the pod is Ready, the interface exists.
|
||||||
|
|
||||||
|
## Step 5 — enrol a device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/vpn-ingress/new-peer.sh pixel-8 2 # full tunnel (LAN + WAN egress)
|
||||||
|
./infra/vpn-ingress/new-peer.sh thinkpad 3 --split # split tunnel (LAN only)
|
||||||
|
```
|
||||||
|
|
||||||
|
It generates the keypair **on your machine**, writes a `0600` client profile outside the checkout,
|
||||||
|
prints a QR code if `qrencode` is installed, and prints the `[Peer]` stanza to add. Then:
|
||||||
|
|
||||||
|
1. Paste the stanza into `platform/vpn-ingress/peers.yaml` under `data.peers.conf`, keeping the
|
||||||
|
`# <name> — <owner>, enrolled <date>` comment. `wg show` can only ever print the key, so that
|
||||||
|
comment is the only record of which stanza belongs to which device.
|
||||||
|
2. PR → merge. Flux rolls the DaemonSet (~1 min).
|
||||||
|
3. Get the profile onto the device (QR scan for phones; "Import tunnel from file" on
|
||||||
|
Windows/macOS; `/etc/wireguard/<name>.conf` + `wg-quick@<name>` on Linux).
|
||||||
|
4. **Delete the profile from your machine**: `shred -u <path>`. It is the only copy of that
|
||||||
|
device's private key — the server never sees it and it cannot be re-derived.
|
||||||
|
|
||||||
|
Until the PR is merged the device handshakes and gets silence, exactly like an unknown key.
|
||||||
|
|
||||||
|
## Step 6 — verify it end to end (from OFF the LAN)
|
||||||
|
|
||||||
|
**This is the only honest test, and it cannot be run from inside the house.** Put the phone on
|
||||||
|
cellular, with Wi-Fi off, and check all four — each proves a different hop:
|
||||||
|
|
||||||
|
| # | Check | Proves |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | tunnel shows a recent handshake in the WireGuard app | the WAN forward + the server key + the peer stanza |
|
||||||
|
| 2 | `ping 192.168.2.1` (the router) | decrypt → FORWARD → LAN, and the SNAT return path |
|
||||||
|
| 3 | open `https://plex.home.keithjackson.dev` | PiHole DNS over the tunnel **and** the Gateway accepting a `host`-identity source — the specific thing a pod-networked VPN would have 403'd |
|
||||||
|
| 4 | visit an IP-echo site; it should show `136.51.115.252` | full-tunnel WAN egress out the house, not the carrier |
|
||||||
|
|
||||||
|
Then confirm server-side:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
|
||||||
|
# a `latest handshake` under ~3 min, and non-zero transfer, on the expected peer.
|
||||||
|
```
|
||||||
|
|
||||||
|
**If check 3 fails while 2 passes**, the tunnel is fine and the problem is DNS or the Gateway, not
|
||||||
|
WireGuard. Check the client's `DNS = 192.168.2.23` and that PiHole is up.
|
||||||
|
|
||||||
|
**If 1 and 2 pass but large transfers hang**, it is MTU. The profile ships `MTU = 1420` (1500 − 60
|
||||||
|
bytes of WireGuard encapsulation); drop the client to `1280` and re-test.
|
||||||
|
|
||||||
|
**If nothing handshakes at all**, you cannot tell a wrong forward from a wrong key from outside —
|
||||||
|
by design. Work inwards: `wg show` on the node holding the forward will show *zero* received bytes
|
||||||
|
if the router is not delivering the packets, and a `handshake` with no completed session if it is.
|
||||||
|
|
||||||
|
## Revoking a device
|
||||||
|
|
||||||
|
Delete its `[Peer]` stanza from `peers.yaml`, PR, merge. Nothing else — the key stops working on the
|
||||||
|
next reconcile. **Never re-use that address octet**: an old profile would silently start working
|
||||||
|
again as a different device.
|
||||||
|
|
||||||
|
## Why there is no connection alert
|
||||||
|
|
||||||
|
A WireGuard exporter would publish `wireguard_latest_handshake_seconds` per peer and make
|
||||||
|
"something connected at 03:00" alertable. It needs a second `NET_ADMIN` container inside the pod
|
||||||
|
that terminates the internet-facing tunnel, and that was not worth shipping unverified. **It is the
|
||||||
|
top follow-up for this component.** Until then:
|
||||||
|
|
||||||
|
* pod-down is covered generically by kube-prometheus-stack's `KubePodNotReady` /
|
||||||
|
`KubeDeploymentReplicasMismatch`;
|
||||||
|
* "who is connected" is an on-demand question — `wg show`, above;
|
||||||
|
* **never** `wg showconf` — it prints the server's private key.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
* **No IPv6 in the tunnel.** A full-tunnel client with native IPv6 still reaches IPv6 sites outside
|
||||||
|
the tunnel. Functionally harmless; a privacy leak if hiding the client's network was the point.
|
||||||
|
* **Client IPs are not preserved** on the LAN side: everything arrives SNATed as the node's
|
||||||
|
address. A static route for `10.90.0.0/24` → the forwarded node, added on the router, would
|
||||||
|
preserve them — at the cost of another hand-maintained off-repo route.
|
||||||
|
* **Rollouts briefly drop the forwarded node.** `maxUnavailable: 1`, so one node at a time; clients
|
||||||
|
re-handshake automatically (retry every 5s, rekey every 2 min).
|
||||||
+169
-47
@@ -23,6 +23,7 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
| `dependencies` | `[]` → reaches **only DNS** (default-deny) | R-NET-01 |
|
| `dependencies` | `[]` → reaches **only DNS** (default-deny) | R-NET-01 |
|
||||||
| `inClusterClients` | `[]` → no direct east-west client; only the Gateway may reach an exposed port | R-NET-01/02 |
|
| `inClusterClients` | `[]` → no direct east-west client; only the Gateway may reach an exposed port | R-NET-01/02 |
|
||||||
| `auth.mode` | `forward-auth` for any exposed UI | R-IDP-02 |
|
| `auth.mode` | `forward-auth` for any exposed UI | R-IDP-02 |
|
||||||
|
| `auth.apiRoutes` | `[]` → the sidecar renders **no** `--api-route`; every unauthenticated request is 302'd to Keycloak | R-IDP-02 |
|
||||||
| `update.channel` | `stable` | R-UPD-01 |
|
| `update.channel` | `stable` | R-UPD-01 |
|
||||||
| `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 |
|
| `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 |
|
||||||
| `observability.metrics` | `true` | R-OBS-01 |
|
| `observability.metrics` | `true` | R-OBS-01 |
|
||||||
@@ -160,61 +161,155 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
||||||
**R-STATE-04** (bulk tier). (docs/04)
|
**R-STATE-04** (bulk tier). (docs/04)
|
||||||
|
|
||||||
|
### `availability` (`replicas`) — workload kind, per-replica volumes, spreading, budget
|
||||||
|
|
||||||
|
**Schema 1.10.0.** Availability INTENT. It names no Kubernetes object, no topology key and no
|
||||||
|
budget — exactly like `hardware.gpu`, and for the same reason: the mechanism below should be
|
||||||
|
replaceable without a catalog edit.
|
||||||
|
|
||||||
|
- **Absent, or `replicas: 1`** → a **Deployment**, standalone PVC(s), `strategy: Recreate` when there
|
||||||
|
is storage, no PodDisruptionBudget. Byte-identical to pre-1.10.0 output, so every existing app is
|
||||||
|
untouched.
|
||||||
|
- **`replicas: N > 1`** → all of the following, together:
|
||||||
|
| Rendered | Why |
|
||||||
|
|---|---|
|
||||||
|
| **StatefulSet** instead of Deployment | A longhorn PVC is **ReadWriteOnce**. One RWO claim cannot attach on two nodes, so a multi-replica Deployment does not degrade — the second pod never schedules. |
|
||||||
|
| longhorn storage becomes **`volumeClaimTemplates`** | Gives each replica its own claim (`<vol>-<svc>-<ordinal>`, e.g. `config-pihole-0`). The template's `metadata.name` **must** equal the pod volume name the container mounts. The standalone PVC is then **not** emitted — it would be an orphan nothing mounts. |
|
||||||
|
| `bulk` storage stays a **standalone PVC** | A bulk entry is a static, pre-bound NFS PV over data that already exists outside the cluster and is shared read-only. Per-replica copies are neither possible nor wanted. |
|
||||||
|
| **required** `podAntiAffinity` on `kubernetes.io/hostname` | `preferred` would let every replica land on one node and deliver none of the intended node-failure tolerance, while every object still reported healthy. |
|
||||||
|
| `podManagementPolicy: Parallel` | `OrderedReady` serialises first-boot work across replicas; `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between replicas to protect. |
|
||||||
|
| **PodDisruptionBudget**, `minAvailable: 1` | Stops a *voluntary* disruption (node drain, upgrade) taking the last replica. It does nothing about a node dying — that is what the replica count and anti-affinity are for. Never rendered at `replicas: 1`, where `minAvailable: 1` would block every drain of that node forever. |
|
||||||
|
| `observability.livenessWorkload.kind` defaults to **`StatefulSet`** | The `up` SLI reads kube-state-metrics by workload kind. A Deployment-shaped rule against a StatefulSet `absent()`s forever — an alert that can never fire. Deriving the default means bumping the replica count cannot leave a stale rule behind. |
|
||||||
|
|
||||||
|
**Two costs, both deliberate, neither hidden:**
|
||||||
|
|
||||||
|
1. **N replicas need N schedulable nodes.** On a 3-node cluster, losing two nodes leaves a replica
|
||||||
|
`Pending`; since `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, that **stalls the
|
||||||
|
app tier**. That coincides with having lost etcd quorum on a 3-node control plane, so nothing is
|
||||||
|
reconciling at that point anyway — the stall is a symptom, not a new failure mode.
|
||||||
|
2. **Only declare `> 1` for a service whose on-disk state is reproducible from git.** A replica with
|
||||||
|
a fresh volume comes up serving whatever it can bootstrap itself and nothing else. For pihole that
|
||||||
|
prerequisite was done first — the admin password and the adlists were declared in
|
||||||
|
`decisions/2026-10-04-pihole-declare-state.md` — because otherwise a second replica would have
|
||||||
|
served DNS while blocking nothing, with a different password.
|
||||||
|
|
||||||
|
**Requirements:** R-PLAT-02 (the workload), R-STATE-01/02 (per-replica replicated volumes),
|
||||||
|
R-OBS-01 (the SLI must follow the rendered kind).
|
||||||
|
|
||||||
### `hardware` (`gpu`) — host device capability
|
### `hardware` (`gpu`) — host device capability
|
||||||
- **The field is INTENT, never MECHANISM.** `hardware.gpu` is an **enum of capability classes**
|
- **The field is INTENT, never MECHANISM.** `hardware.gpu` is an **enum of capability classes**
|
||||||
(today: `intel-quicksync`), not a device path, not a hostPath, not a resource request. The
|
(today: `intel-quicksync`), not a device path, not a hostPath, not a resource request. The
|
||||||
ServiceDefinition says *what class of host device this workload needs*; how the platform hands it
|
ServiceDefinition says *what class of host device this workload needs*; how the platform hands it
|
||||||
over is `catalog/render.py`'s business and lives nowhere else. **Do not "simplify" this into a raw
|
over is `catalog/render.py`'s business and lives nowhere else. **Do not "simplify" this into a raw
|
||||||
`/dev/...` path.** The entire payoff is that migrating from the hostPath mechanism below to the
|
`/dev/...` path.** The payoff is no longer hypothetical: on **2026-10-01** the mechanism was
|
||||||
Intel GPU device plugin (`resources.limits: gpu.intel.com/i915`) is a renderer change with **zero**
|
swapped outright — from a `/dev/dri` hostPath to the Intel GPU device plugin's extended resource —
|
||||||
catalog edits and zero re-review of per-service device wiring. Extend the enum for a genuinely
|
as a **`catalog/render.py` change plus a re-render, with ZERO catalog edits** and no re-review of
|
||||||
different capability class (e.g. `nvidia-nvenc`); never to name a second route to the same one.
|
per-service device wiring. `catalog/plex.yaml`'s `hardware:` block came through byte-identical.
|
||||||
- `gpu: intel-quicksync` → on the **app container only** (never a sidecar), exactly two things:
|
Extend the enum for a genuinely different capability class (e.g. `nvidia-nvenc`); never to name a
|
||||||
1. a **hostPath volume** `dri` → `/dev/dri`, **`type: DirectoryOrCreate`**;
|
second route to the same one.
|
||||||
2. a **read-write volumeMount** of `dri` at `/dev/dri` on the app container (VA-API opens the
|
- `gpu: intel-quicksync` → on the **app container only** (never a sidecar), **exactly one thing**:
|
||||||
render node read-write, so it is deliberately not `readOnly`).
|
|
||||||
|
|
||||||
**Mounting the device is sufficient. That is the whole mapping.**
|
resources:
|
||||||
|
limits:
|
||||||
|
gpu.intel.com/i915: 1
|
||||||
|
|
||||||
|
**That is the whole mapping.** No volume, no volumeMount, no `securityContext`, no `nodeSelector`,
|
||||||
|
no privileged anything.
|
||||||
|
- **`limits`, not `requests`, and that is not a style choice.** An extended resource must be
|
||||||
|
expressed in `limits`; Kubernetes mirrors `requests` from `limits` automatically, and a
|
||||||
|
requests-only extended resource is rejected as invalid.
|
||||||
|
- The resource is advertised by the **`intel-gpu-plugin` DaemonSet** (`platform/intel-gpu-plugin/`,
|
||||||
|
its own Flux Kustomization at `clusters/base/intel-gpu-plugin.yaml`, pinned
|
||||||
|
`intel/intel-gpu-plugin:0.37.0`). **Measured 2026-10-01:** all three prod nodes report
|
||||||
|
`.status.allocatable["gpu.intel.com/i915"] == 1`; DaemonSet 3 desired / 3 ready / 3 available.
|
||||||
|
- This is the **only** app-container `resources` render path in the file, and it is deliberately
|
||||||
|
narrow. **Do not generalise it into a CPU/memory resources feature** while passing through — that
|
||||||
|
is a separate field with a separate justification, and `render.py --selftest` asserts the emitted
|
||||||
|
dict *exactly* so the attempt fails loudly.
|
||||||
|
- **WHY THE hostPath MECHANISM WAS ABANDONED — read this before "simplifying" it back.** The first
|
||||||
|
implementation (merged 2026-09-30) rendered a `dri` hostPath volume at `/dev/dri` plus a read-write
|
||||||
|
mount. It was **measured not to work** in the live Plex pod on k3s-prod-2 on **2026-10-01**. The
|
||||||
|
device node was **visible** in the container — `crw-rw---- root video2 226,128 renderD128` — and
|
||||||
|
**could not be opened**:
|
||||||
|
|
||||||
|
| attempt | result |
|
||||||
|
|---|---|
|
||||||
|
| `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
| `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
||||||
|
|
||||||
|
`EPERM` is the **device cgroup** refusing, not `EACCES` from file permissions — the two look
|
||||||
|
identical from the outside and are not the same problem. Proven three ways: root on the **host**
|
||||||
|
opens the same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc
|
||||||
|
`config.json` reads `linux.resources.devices: [{allow: False, access: 'rwm'}]` with
|
||||||
|
`linux.devices: None` — **one deny-all rule, no allow entry for major 226**. A hostPath is a
|
||||||
|
*filesystem* operation: it binds the device node in but adds **no cgroup allow rule**, because
|
||||||
|
Kubernetes cannot infer one from a volume. **Note that `uid 0` failed too** — root does not bypass
|
||||||
|
the cgroup device gate, so "run it as root" is not a workaround. Neither is `privileged: true`:
|
||||||
|
Kyverno `restrict-host-namespaces` is **Enforce** on app namespaces and rejects it.
|
||||||
|
The device plugin is what produces the allow rule — confirmed in the v0.37.0 source,
|
||||||
|
`createDeviceSpecsFromDrmFiles` emits `DeviceSpec{…, Permissions: "rw"}`, which the kubelet turns
|
||||||
|
into both the device injection and the matching cgroup allow.
|
||||||
|
- **THE FAILURE MODE THIS TRADED INTO IS WORSE IN BLAST RADIUS, AND IT IS UNAVOIDABLE.** Stated
|
||||||
|
plainly rather than sold:
|
||||||
|
|
||||||
|
| | device/resource unavailable ⇒ |
|
||||||
|
|---|---|
|
||||||
|
| **Before** (hostPath, `DirectoryOrCreate`) | kubelet mounts an empty dir; Plex runs and **silently software-transcodes**. Invisible, but harmless to everything else. |
|
||||||
|
| **Now** (extended resource) | the pod is **Unschedulable**. `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, so this **stalls the app tier**. |
|
||||||
|
|
||||||
|
There is **no graceful-degradation variant**: an extended resource is a hard scheduling
|
||||||
|
constraint, and it is also the only thing that produces the cgroup allow rule. The workload's
|
||||||
|
schedulability is now **coupled to the `intel-gpu-plugin` DaemonSet** — a plugin that is broken,
|
||||||
|
removed, or merely not yet reconciled takes the app tier with it. That is the price of hardware
|
||||||
|
transcoding working at all.
|
||||||
|
- **Escape hatch, one field and no code change:** delete the `hardware:` block from the
|
||||||
|
ServiceDefinition (`catalog/plex.yaml`) and re-render. The workload returns to software
|
||||||
|
transcoding and the tier unblocks. `runbooks/plex-quicksync.md` carries it as a first-class
|
||||||
|
procedure, because it is what someone will need at 2am.
|
||||||
|
- The old mechanism's cost is **gone**, and that is the one improvement: a missing device is no
|
||||||
|
longer silent. There is still no metric and no alert (media apps here render no
|
||||||
|
`metricsEndpoint`, so no ServiceMonitor exists), but "Plex is Unschedulable" is loud, whereas
|
||||||
|
"Plex is quietly software-transcoding" never was.
|
||||||
- **`podSecurityContext.supplementalGroups` is deliberately NOT rendered, and adding it would be a
|
- **`podSecurityContext.supplementalGroups` is deliberately NOT rendered, and adding it would be a
|
||||||
regression rather than a hardening.** `/dev/dri/renderD128` is `root:render` `0660`, so the obvious
|
regression rather than a hardening.** This survives the mechanism swap unchanged, and it was
|
||||||
|
re-measured against the plugin's behaviour. `renderD128` is `root:render` `0660`, so the obvious
|
||||||
move is to push the host `render` GID in — and it does not work. **Measured in the running
|
move is to push the host `render` GID in — and it does not work. **Measured in the running
|
||||||
container on prod-2, 2026-09-30:** the image's own
|
container on prod-2 (2026-09-30, re-measured 2026-10-01):** the image's own
|
||||||
`/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` hook `find`s every char device under
|
`/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` hook `find`s every char device under
|
||||||
`/dev/dri`, `stat -c '%g'` each one's GID, `groupadd`s a matching group *inside the container* when
|
`/dev/dri`, `stat -c '%g'` each one's GID, `groupadd`s a matching group *inside the container* when
|
||||||
none exists, and `usermod -a -G` the app user into it — every start, nothing hardcoded.
|
none exists, and `usermod -a -G` the app user into it — every start, nothing hardcoded. It does
|
||||||
`cont-init.d` completes before `services.d`, so the account database is already correct when the
|
exactly the same thing to a **plugin-injected** device as to a bind-mounted one, which is why this
|
||||||
service execs. The service then starts via `exec s6-setuidgid plex …`, and `s6-setuidgid`
|
swap needed no group work. `cont-init.d` completes before `services.d`, so the account database is
|
||||||
**replaces** the process group list from `/etc/group` rather than appending to it:
|
already correct when the service execs. The service then starts via `exec s6-setuidgid plex …`, and
|
||||||
`Plex Media Server` runs as `Uid: 1000 Gid: 1000 Groups: 100 1000`, with root's gid `0` — which
|
`s6-setuidgid` **replaces** the process group list from `/etc/group` rather than appending to it —
|
||||||
PID 1 (`s6-svscan`) does hold — **gone**. A Kubernetes `supplementalGroups` therefore reaches
|
measured live, `Plex Media Server` ran with `Groups: 44 100 992 1000`, where **992 is the host
|
||||||
`s6-svscan` and is discarded before the app ever starts. It would be inert *and* it would mislead
|
`render` GID**, while root's gid `0` (which PID 1, `s6-svscan`, does hold) was **gone**. A
|
||||||
whoever debugs this next. The renderer asserts its own absence in `render.py --selftest`.
|
Kubernetes `supplementalGroups` therefore reaches `s6-svscan` and is discarded before the app ever
|
||||||
|
starts. It would be inert *and* it would mislead whoever debugs this next. The renderer asserts its
|
||||||
|
own absence in `render.py --selftest`.
|
||||||
- A corollary worth having: because the hook re-`stat`s the device on every start, the host GID
|
- A corollary worth having: because the hook re-`stat`s the device on every start, the host GID
|
||||||
**drifting** (a node rebuild renumbering the *systemd dynamic* `render` group) is self-healing.
|
**drifting** (a node rebuild renumbering the *systemd dynamic* `render` group) is self-healing.
|
||||||
No GID is pinned anywhere in this repo.
|
No GID is pinned anywhere in this repo.
|
||||||
- **Scope of that measurement, stated so nobody over-reads it.** "Mounting the device is
|
- **Scope of that measurement, stated so nobody over-reads it.** "The group is the image's job" was
|
||||||
sufficient" was established for an image that resolves the device group itself. A FUTURE
|
established for an image that resolves the device group itself. A FUTURE consumer that runs
|
||||||
consumer that runs non-root and has no equivalent init hook would still need the group solved
|
non-root and has no equivalent init hook would still need the group solved somehow — and the
|
||||||
somehow — and the answer for that case is a **renderer** change (per-image handling, or the
|
answer for that case is a **renderer** change (per-image handling), not a new ServiceDefinition
|
||||||
device plugin), not a new ServiceDefinition field. The enum is what keeps that option open. The
|
field. The enum is what keeps that option open, and the 2026-10-01 swap is the proof it works.
|
||||||
first such consumer must re-measure rather than assume this bullet covers it.
|
The first such consumer must re-measure rather than assume this bullet covers it.
|
||||||
- **`DirectoryOrCreate` is load-bearing — do not tighten it to `Directory`.** A node that comes back
|
|
||||||
without the render device (rebuild, BIOS change, driver regression, hardware swap) must degrade the
|
|
||||||
workload to **software transcoding**, not fail the mount. `apps-wave-0` is `wait: true` and
|
|
||||||
`apps-wave-1` `dependsOn` it, so a failed hostPath mount stalls the **whole app tier** for the sake
|
|
||||||
of a feature that is not load-bearing. `/dev` is tmpfs, so the empty directory the kubelet creates
|
|
||||||
on such a node leaves nothing durable behind.
|
|
||||||
- **The cost of that choice, stated plainly:** a missing device is **silent**. The workload
|
|
||||||
software-transcodes and nothing reports it. There is no metric to alert on — media apps here
|
|
||||||
render no `metricsEndpoint`, so no ServiceMonitor exists — and an alert that cannot fire is worse
|
|
||||||
than none (see §`observability`'s 2026-09-13 correction). The only proof of hardware transcode is
|
|
||||||
an operator observing it in the application's own dashboard.
|
|
||||||
- **Deliberately NOT rendered** from this field: no `privileged: true` (Kyverno
|
- **Deliberately NOT rendered** from this field: no `privileged: true` (Kyverno
|
||||||
`restrict-host-namespaces` is Enforce on app namespaces and would reject it, and the render node
|
`restrict-host-namespaces` is Enforce on app namespaces and would reject it, and it would not have
|
||||||
does not need it); no `nodeSelector` (placement is a separate concern with its own justification —
|
helped anyway — see the `EPERM` table); no `nodeSelector` (placement is a separate concern with its
|
||||||
this field is about the device, not about where the pod lands); no resource requests/limits (there
|
own justification — this field is about the device, not about where the pod lands; the extended
|
||||||
is no app-container resource render path, and inventing one here would be scope creep).
|
resource does constrain placement to nodes the plugin advertises, but that is the **scheduler's**
|
||||||
- Absent ⇒ **nothing at all** is rendered; every service that does not opt in stays byte-identical.
|
doing, not a selector this renderer writes); no CPU/memory requests or limits (see above — the
|
||||||
|
`resources` block carries the extended resource and nothing else).
|
||||||
|
- **Historical, kept so a reader recognises it rather than reintroduces it:**
|
||||||
|
`type: DirectoryOrCreate` on the retired hostPath was load-bearing *for that mechanism* — it is
|
||||||
|
what made a GPU-less node degrade to software transcoding instead of failing the mount and
|
||||||
|
stalling the app tier. That rationale died with the hostPath; it is **not** an argument for bringing it back, since
|
||||||
|
the mechanism it protected never granted access in the first place.
|
||||||
|
- Absent ⇒ **nothing at all** is rendered; every service that does not opt in stays byte-identical
|
||||||
|
(no `resources` key, no volume, no mount, no `securityContext`).
|
||||||
Ignored for `kind: vm` — a VM receives devices through KubeVirt's own `domain.devices`.
|
Ignored for `kind: vm` — a VM receives devices through KubeVirt's own `domain.devices`.
|
||||||
- **Requirements:** **R-PLAT-02** (the PaaS provides the host capability; the app declares need).
|
- **Requirements:** **R-PLAT-02** (the PaaS provides the host capability; the app declares need).
|
||||||
|
|
||||||
@@ -260,7 +355,7 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
undeclared), **R-GITOPS-02** (the allowance is authored in the one reviewable object rather than
|
undeclared), **R-GITOPS-02** (the allowance is authored in the one reviewable object rather than
|
||||||
surviving as a live-applied cluster object). Auditable via Hubble (R-SEC-03, R-OBS-04).
|
surviving as a live-applied cluster object). Auditable via Hubble (R-SEC-03, R-OBS-04).
|
||||||
|
|
||||||
### `auth` (`mode` / `groups[]`) — identity integration (Keycloak / oauth2-proxy)
|
### `auth` (`mode` / `groups[]` / `apiRoutes[]`) — identity integration (Keycloak / oauth2-proxy)
|
||||||
- `oidc` → register the app as a **Keycloak OIDC client** (declarative realm/client config, GitOps;
|
- `oidc` → register the app as a **Keycloak OIDC client** (declarative realm/client config, GitOps;
|
||||||
client secret SOPS-encrypted). App does its own login redirect.
|
client secret SOPS-encrypted). App does its own login redirect.
|
||||||
- `forward-auth` → protect the service's `ingressRoutes` at the **Gateway** with **oauth2-proxy →
|
- `forward-auth` → protect the service's `ingressRoutes` at the **Gateway** with **oauth2-proxy →
|
||||||
@@ -271,6 +366,20 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
- `groups[]` → the Keycloak groups permitted access, enforced by the OIDC client mappers /
|
- `groups[]` → the Keycloak groups permitted access, enforced by the OIDC client mappers /
|
||||||
oauth2-proxy `allowed_groups`. **Schema requires ≥1 group for `oidc`/`forward-auth`** — access is
|
oauth2-proxy `allowed_groups`. **Schema requires ≥1 group for `oidc`/`forward-auth`** — access is
|
||||||
"in an authorized group," not merely "logged in."
|
"in an authorized group," not merely "logged in."
|
||||||
|
- `apiRoutes[]` (**`forward-auth` only**) → one repeatable **`--api-route=<regex>`** per entry on
|
||||||
|
the per-app oauth2-proxy sidecar, making matching paths answer **401** instead of a **302** to
|
||||||
|
Keycloak. Default `[]` renders **no flag at all**, which is the common case. It exists because
|
||||||
|
oauth2-proxy's built-in AJAX guard fires only on an exact `Accept: application/json`, which
|
||||||
|
these apps' JavaScript does not consistently send: the XHR is 302'd, the browser follows it
|
||||||
|
invisibly, the live SSO session satisfies it silently, and the call resolves 200 carrying the
|
||||||
|
app's **HTML** where the JavaScript expected JSON — and a POST following a 302 is re-issued as
|
||||||
|
a GET with its **body dropped**. It is **per-service and must never become a renderer
|
||||||
|
constant**: the path shape belongs to the app (radarr `/signalr/`, jackett `/api/`, pihole
|
||||||
|
none), and a wrong constant does not merely fail to help — a path a **browser navigates to**,
|
||||||
|
answered 401, shows a logged-out operator a bare error page and never offers a sign-in. Each
|
||||||
|
entry must be **anchored at `^/`** and continue with literal path characters; `^/`, `^/.*`,
|
||||||
|
`.*`, an unanchored `/api/` and any alternation are **rejected** by the schema and by the
|
||||||
|
renderer (alternation is refused rather than supported because the flag is repeatable).
|
||||||
- Keycloak, oauth2-proxy, and app→Keycloak / proxy→Keycloak / Keycloak→DB flows are ordinary
|
- Keycloak, oauth2-proxy, and app→Keycloak / proxy→Keycloak / Keycloak→DB flows are ordinary
|
||||||
workloads under default-deny with explicit allows (ties back to `dependencies`).
|
workloads under default-deny with explicit allows (ties back to `dependencies`).
|
||||||
- **Requirements:** **R-IDP-02** (protect OIDC-capable and bare UIs), **R-IDP-06** (group-based
|
- **Requirements:** **R-IDP-02** (protect OIDC-capable and bare UIs), **R-IDP-06** (group-based
|
||||||
@@ -329,6 +438,12 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
## Cross-field invariants the renderer / L0-L1 tests rely on
|
## Cross-field invariants the renderer / L0-L1 tests rely on
|
||||||
1. **kind ↔ source:** container⇒`image`, vm⇒`vmTemplate` (schema-enforced).
|
1. **kind ↔ source:** container⇒`image`, vm⇒`vmTemplate` (schema-enforced).
|
||||||
2. **auth ↔ groups:** `oidc`/`forward-auth` ⇒ non-empty `groups` (schema-enforced; R-IDP-06).
|
2. **auth ↔ groups:** `oidc`/`forward-auth` ⇒ non-empty `groups` (schema-enforced; R-IDP-06).
|
||||||
|
2a. **auth ↔ apiRoutes:** a non-empty `auth.apiRoutes` ⇒ `auth.mode: forward-auth`
|
||||||
|
(schema-enforced). It renders onto the per-app oauth2-proxy sidecar, and no sidecar exists for
|
||||||
|
`oidc`/`native`/`none`, so declaring it elsewhere would be a **silent no-op** — the operator
|
||||||
|
believes the app's XHRs now answer 401 and nothing changed. Every entry must be anchored at
|
||||||
|
`^/` (schema-enforced **and** enforced in `catalog/render.py`, so a breadth mistake is caught by
|
||||||
|
`render-golden` even on a run where the schema stage did not execute).
|
||||||
3. **default-deny is unconditional:** every namespace gets the deny baseline regardless of
|
3. **default-deny is unconditional:** every namespace gets the deny baseline regardless of
|
||||||
`dependencies`; `dependencies` only *adds* allows (R-NET-01/02). The same holds for
|
`dependencies`; `dependencies` only *adds* allows (R-NET-01/02). The same holds for
|
||||||
`inClusterClients`: it adds one namespace+port ingress allow and never relaxes the baseline.
|
`inClusterClients`: it adds one namespace+port ingress allow and never relaxes the baseline.
|
||||||
@@ -344,11 +459,18 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
|||||||
`external-dns.alpha.kubernetes.io/controller: none`, and no `public` service's route carries it.
|
`external-dns.alpha.kubernetes.io/controller: none`, and no `public` service's route carries it.
|
||||||
Derived from `exposure` alone; enforced on every gate run by
|
Derived from `exposure` alone; enforced on every gate run by
|
||||||
`conformance/tests/l0-static/internal-services-publish-no-public-dns` (R-DNS-03).
|
`conformance/tests/l0-static/internal-services-publish-no-public-dns` (R-DNS-03).
|
||||||
8. **`hardware` is inert until opted into, and its mechanism is renderer-local.** An SD with no
|
8. **`hardware` is inert until opted into, and its mechanism is renderer-local — now DEMONSTRATED,
|
||||||
`hardware` key renders no volume and no volumeMount; an SD *with* it gains no `securityContext`
|
not merely claimed.** An SD with no `hardware` key renders no `resources` block, no volume and no
|
||||||
either. Both directions are proven by `render.py --selftest`. The `/dev/dri` hostPath and the
|
volumeMount; an SD *with* it gains a `gpu.intel.com/i915: 1` **limit on the app container only**
|
||||||
`DirectoryOrCreate` type appear in `catalog/render.py` and nowhere else, so the capability's
|
(never a sidecar) and no `securityContext` either. All directions are proven by
|
||||||
implementation can be replaced without touching a single ServiceDefinition.
|
`render.py --selftest`, and each assertion is mutation-tested. The resource name
|
||||||
|
`gpu.intel.com/i915` appears in `catalog/render.py` and nowhere else in the catalog path, so the
|
||||||
|
capability's implementation can be replaced without touching a single ServiceDefinition — which
|
||||||
|
is exactly what happened on **2026-10-01**, when the mechanism moved from a `/dev/dri` hostPath
|
||||||
|
(measured non-functional: device-cgroup `EPERM`) to the device plugin's extended resource with
|
||||||
|
**zero** catalog edits. The cost of that mechanism is recorded in §`hardware`: the workload is now
|
||||||
|
**Unschedulable** when the resource is absent, which stalls the app tier, and the escape hatch is
|
||||||
|
to delete the `hardware:` block and re-render.
|
||||||
|
|
||||||
## Requirement traceability (contract-level)
|
## Requirement traceability (contract-level)
|
||||||
Primary **R-GITOPS-02** (single reviewable object wires every requirement). Field-level: R-NET-01/02
|
Primary **R-GITOPS-02** (single reviewable object wires every requirement). Field-level: R-NET-01/02
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||||
"title": "ServiceDefinition",
|
"title": "ServiceDefinition",
|
||||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. It sets NO podSecurityContext.supplementalGroups: measured in-container on prod 2026-09-30, the pms-docker cont-init hook adds the app user to the device's group inside the container and `s6-setuidgid` then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied, so such an entry would be inert and misleading. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.md already said `internal` renders no public DNS record; nothing enforced it, and six internal services -- jackett, pihole, qbittorrent, radarr, semaphore, sonarr -- were publishing `<name>.home.keithjackson.dev` into the PUBLIC Cloudflare zone answering with the RFC1918 Gateway VIP, which published the internal subnet and a full service inventory and made each name a DNS-rebind primitive. Enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns). 1.6.0 (splits `observability.metrics` from WHERE metrics live: adds optional `metricsEndpoint` (port/name/path/interval) and optional `livenessWorkload` (kind/name). `metrics: true` previously rendered a ServiceMonitor hardcoded to a port named `metrics` that no rendered Service ever defined, so all twelve app monitors discovered ZERO targets and every `up{job=...}` SLI was structurally inert on prod; a monitor is now rendered only from a declared `metricsEndpoint`, which also renders the Service port and the monitoring-namespace ingress allow, and the `up` SLI is expressed against kube-state-metrics -- a signal that exists for every workload without a per-app exporter. `livenessWorkload` names the object whose readiness IS the service, for the case where the running workload is not the one this SD renders). 1.5.0 (adds optional `inClusterClients` — additional in-cluster clients, named by NAMESPACE, admitted to a declared port of this service DIRECTLY, bypassing the shared Gateway. `ingressRoutes` admits only the Gateway proxy identity and `dependencies` only links catalog services, so a PLATFORM controller dialling a Service ClusterIP had no authorable form at all; that gap is what deadlocked prod's GitOps when its GitRepository moved to the in-cluster Gitea Service. Renders one least-privilege ingress CiliumNetworkPolicy per entry — the named namespace and the declared port, nothing wider). 1.4.0 (makes the `bulk` tier REAL: a bulk storage entry now carries `export` (the absolute NFS export path on the ${BULK_NFS_SERVER} host) and renders a STATIC, pre-bound NFS PersistentVolume + PVC over data that already exists outside the cluster, plus optional `subPath` and `readOnly` on any entry. Resolves the OQ-2 bulk-backing stub for STATIC pre-existing datasets; dynamic bulk provisioning remains unimplemented and unneeded). 1.3.0 added optional pod-level podSecurityContext for the container kind — k8s PodSecurityContext emitted verbatim onto the pod spec; its fsGroup unblocks non-root images e.g. semaphore/UID-1001 writing a mounted Longhorn PVC). 1.2.0 added optional per-container env/envFrom/lifecycle for the container kind — k8s EnvVar/EnvFromSource/Lifecycle shapes emitted verbatim onto the app container; unblocks headless-install/DB-backed apps e.g. gitea/nextcloud/semaphore/unifi/minecraft. 1.1.0 added the shape invariant exposure:public => auth.mode!=none (docs/13 v1.1 / docs/15 Line truncated
|
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.11.0 (adds optional `optional` to env.valueFrom.secretKeyRef and env.valueFrom.configMapKeyRef -- the k8s EnvVarSource field, emitted verbatim. Without it a secretKeyRef was ALWAYS hard: a missing Secret meant the pod would not start at all. That is the right default and stays the default, but it cannot express a value the workload is designed to run without. Added for plex, whose TLS material is issued by cert-manager INTO ITS OWN NAMESPACE and therefore cannot exist before the app wave that creates that namespace -- the Certificate and the Deployment land in the same reconcile, so on a cold bootstrap the Secret is briefly absent while a DNS01 challenge completes. Hard refs there would have made the media server unstartable until a certificate issued, and `apps-wave-0` is `wait: true`, so a failed or slow issuance would have STALLED THE WHOLE APP TIER -- the same hazard catalog/plex.yaml already documents for its GPU resource. With `optional: true` Plex starts regardless, its postStart hook logs that the certificate is not installed, and the next start after issuance converges. Renders nothing when absent, so every existing app stays byte-identical). 1.10.0 (adds optional `availability` for the container kind, one member: `availability.replicas`. It is AVAILABILITY INTENT, not a scheduling primitive -- it names no Kubernetes object, no topology key and no budget. 1 (the default) renders a Deployment exactly as before, so every pre-existing app stays byte-identical; >1 means the service must survive the loss of a single node, and the renderer satisfies that with a StatefulSet whose longhorn volumes become per-replica volumeClaimTemplates (a longhorn PVC is ReadWriteOnce and cannot attach on two nodes, so a multi-replica Deployment is UNSCHEDULABLE, not merely suboptimal), REQUIRED podAntiAffinity on kubernetes.io/hostname (preferred would let every replica land on one node and deliver none of the intended tolerance while reporting healthy), podManagementPolicy Parallel, a PodDisruptionBudget of minAvailable 1, and an `up` SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever and can never fire). Added for pihole, where LAN DNS -- a critical network service -- was one pod on one node: measured 2026-10-04, losing the node running it meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 plus node-NotReady detection plus a Longhorn RWO force-detach). Deliberately NOT usable until a service's state is reproducible from git, because a replica with a fresh volume otherwise comes up serving but unconfigured -- for pihole that prerequisite was done first (decisions/2026-10-04-pihole-declare-state.md). Capped at 5: a count above the node count can never schedule under the anti-affinity this implies. Mechanism documented in schemas/renderer-contract.md `### availability`). 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device pluLine truncated
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "source"],
|
"required": ["name", "source"],
|
||||||
@@ -69,7 +69,8 @@
|
|||||||
"required": ["name", "key"],
|
"required": ["name", "key"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "minLength": 1 },
|
"name": { "type": "string", "minLength": 1 },
|
||||||
"key": { "type": "string", "minLength": 1 }
|
"key": { "type": "string", "minLength": 1 },
|
||||||
|
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configMapKeyRef": {
|
"configMapKeyRef": {
|
||||||
@@ -78,7 +79,8 @@
|
|||||||
"required": ["name", "key"],
|
"required": ["name", "key"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "minLength": 1 },
|
"name": { "type": "string", "minLength": 1 },
|
||||||
"key": { "type": "string", "minLength": 1 }
|
"key": { "type": "string", "minLength": 1 },
|
||||||
|
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,21 @@
|
|||||||
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"availability": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional availability intent for the container kind: how many replicas of this workload must be running. INTENT ONLY -- it names no Kubernetes object and no scheduling primitive. How the platform satisfies it (workload kind, per-replica volumes, node spreading, disruption budget) is catalog/render.py's business, documented in schemas/renderer-contract.md `### availability`. Absent => replicas 1 and a Deployment, so every pre-existing app renders byte-identical. Ignored for kind=vm (a VM's availability is a different problem with different primitives).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"properties": {
|
||||||
|
"replicas": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "How many replicas must be running. 1 (default) renders a Deployment exactly as before. >1 declares that the service must survive the loss of a single node; the renderer then emits a StatefulSet with per-replica volumeClaimTemplates, required per-node anti-affinity, podManagementPolicy Parallel and a PodDisruptionBudget. Only declare >1 for a service whose on-disk state is reproducible from git -- a replica with a fresh volume comes up serving whatever it can bootstrap itself, and nothing else.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 5,
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"hardware": {
|
"hardware": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional host HARDWARE CAPABILITY the workload needs, for the container kind (docs/13). This field declares INTENT (`what device class this service needs`), never MECHANISM (`which host path to bind-mount`, `which extended resource to request`). That separation is the whole point of the enum: the platform currently satisfies `intel-quicksync` with a /dev/dri hostPath volume and a matching mount on the app container, but a later migration to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath) must be a catalog/render.py change and NOTHING else - no ServiceDefinition may need editing, and no reviewer may have to re-derive per-service device wiring. Do NOT 'simplify' this into a raw device path, a hostPath, or a resource request; that would push a platform implementation detail into the operator-facing contract and make the migration a catalog-wide edit. Absent => no device wiring at all (existing apps render byte-identical). Ignored for kind=vm (a VM gets devices through KubeVirt's own domain.devices).",
|
"description": "Optional host HARDWARE CAPABILITY the workload needs, for the container kind (docs/13). This field declares INTENT (`what device class this service needs`), never MECHANISM (`which host path to bind-mount`, `which extended resource to request`). That separation is the whole point of the enum: the platform currently satisfies `intel-quicksync` with a /dev/dri hostPath volume and a matching mount on the app container, but a later migration to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath) must be a catalog/render.py change and NOTHING else - no ServiceDefinition may need editing, and no reviewer may have to re-derive per-service device wiring. Do NOT 'simplify' this into a raw device path, a hostPath, or a resource request; that would push a platform implementation detail into the operator-facing contract and make the migration a catalog-wide edit. Absent => no device wiring at all (existing apps render byte-identical). Ignored for kind=vm (a VM gets devices through KubeVirt's own domain.devices).",
|
||||||
@@ -324,6 +341,16 @@
|
|||||||
"items": { "type": "string", "minLength": 1 },
|
"items": { "type": "string", "minLength": 1 },
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"default": []
|
"default": []
|
||||||
|
},
|
||||||
|
"apiRoutes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "forward-auth ONLY: path regexes this app serves as a MACHINE API, which the oauth2-proxy sidecar must answer with 401 instead of a 302 to Keycloak (one --api-route per entry; the flag is repeatable and singular -- --api-routes does not exist in v7.8.1 and the proxy refuses to start with it). WHY THIS IS PER-APP AND NOT A RENDERER CONSTANT: oauth2-proxy's built-in AJAX guard fires only on an EXACT `Accept: application/json`, which these apps' JavaScript does not consistently send, so an unauthenticated XHR gets a 302 that the browser follows invisibly, the live Keycloak SSO session satisfies silently, and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON -- no prompt, no error, and on a write it is worse, because a browser following a 302 re-issues a POST as a GET and DROPS THE BODY. Measured on jackett 2026-09-30: 33 x 302 against 2 x 401 on /api/ paths, with 4 of 6 POSTs answered by the proxy and never reaching the app (access-log upstream field `-`). Measured on radarr 2026-10-01, 23h after the cookie fix deployed: 232 x 302, ALL of them POST /signalr/messages/negotiate from the operator's own browser -- a left-open tab whose cookie the rename orphaned, polling forever, silently redirected every time and NEVER re-prompting. But the path shape is a property of the APP, not of the pattern: radarr's broken path is /signalr/ and jackett's is /api/, and pihole serves no /api/ at all, so a renderer-wide constant would apply one app's path shape to five. Declare only paths this app actually serves as JSON/RPC and that a browser never NAVIGATES to. BREADTH IS THE DANGER: too broad and a plain page load answers 401 instead of signing the operator in, i.e. the service looks permanently broken to a logged-out user and no sign-in is ever offered. The item pattern therefore REQUIRES each entry to be anchored at `^/` and to continue with literal path characters, which rejects `^/`, `^/.*`, `.*` and any unanchored or alternated regex; alternation is refused rather than supported because the flag is repeatable, so one regex per route costs nothing and keeps each entry readable. N/A for oidc/native/none (no sidecar is rendered).",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\^/[A-Za-z0-9._~/-]+(\\.\\*|\\$)?$"
|
||||||
|
},
|
||||||
|
"uniqueItems": true,
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -471,6 +498,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$comment": "auth.apiRoutes only has a consumer under forward-auth: it renders --api-route onto the per-app oauth2-proxy sidecar, and no sidecar is rendered for oidc/native/none. Declaring it elsewhere would be a SILENT NO-OP -- the operator believes the app's XHRs now answer 401 and nothing changed -- so it is rejected at the schema instead (R-IDP-02).",
|
||||||
|
"if": {
|
||||||
|
"required": ["auth"],
|
||||||
|
"properties": {
|
||||||
|
"auth": {
|
||||||
|
"required": ["apiRoutes"],
|
||||||
|
"properties": { "apiRoutes": { "minItems": 1 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"auth": {
|
||||||
|
"required": ["mode"],
|
||||||
|
"properties": { "mode": { "const": "forward-auth" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$comment": "public exposure must not be unauthenticated: an explicit auth.mode 'none' is disallowed on public services (docs/13 v1.1, docs/15, R-IDP-02). Absent auth defaults to forward-auth at render time, so it is permitted here.",
|
"$comment": "public exposure must not be unauthenticated: an explicit auth.mode 'none' is disallowed on public services (docs/13 v1.1, docs/15, R-IDP-02). Absent auth defaults to forward-auth at render time, so it is permitted here.",
|
||||||
"if": {
|
"if": {
|
||||||
|
|||||||
Reference in new issue
Block a user