Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5.5 788eaeae62 fix(node-baseline): pin the OIDC issuer to the Gateway VIP on prod nodes
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
After public DNS moved to the WAN IP (#40), prod nodes resolve
keycloak.home.keithjackson.dev to a WAN address the router does not
hairpin, so the apiserver could not reach its OIDC issuer. Adds an opt-in
node_baseline_hosts_pins /etc/hosts block; prod pins keycloak to
GATEWAY_VIP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 17:39:37 -05:00
jakman85 576e1abb19 Merge pull request 'feat(dns): publish Gateway names to the WAN via in-cluster DDNS' (#40) from feat/public-dns-wan-target into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #40
2026-10-03 22:25:13 +00:00
jakman85andClaude Opus 5.5 a1e808e697 feat(dns): publish Gateway names to the WAN via in-cluster DDNS
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
Gateway-published records now CNAME to wan.${BASE_DOMAIN}, which a
favonia/cloudflare-ddns Deployment keeps at the home WAN's current public
IP. Operator decision: every Gateway-published name goes public; internal
services (controller: none) remain unpublished.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 16:59:53 -05:00
jakman85 bcb8931b4f Merge pull request 'fix(runbooks): Plex hw transcoding WORKS — the dashboard was never the proof' (#39) from fix/plex-quicksync-verification into main
gate / gate (push) Successful in 1m44s
gate / conformance (push) Has been skipped
Reviewed-on: #39
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 06:43:04 +00:00
jakman85 0d7f94098b Merge branch 'main' into fix/plex-quicksync-verification
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
2026-10-02 06:37:04 +00:00
jakman85 5ffd0c98bc Merge pull request 'fix(ci): let an administrator merge their own green PR — required_approvals 1 → 0, plus an attributed /approve-override' (#38) from fix/admin-approval-override into main
gate / gate (push) Successful in 1m44s
gate / conformance (push) Has been skipped
Reviewed-on: #38
Reviewed-by: Keith Jackson <[email protected]>
2026-10-02 06:17:17 +00:00
jakman85andClaude Opus 5 f0994c11a0 docs(decisions): record the PR's measured CI result — gitea-settings has drifted
gate / gate (pull_request) Failing after 1m44s
gate / conformance (pull_request) Has been skipped
Read PR #39's own gate log rather than the tick (CLAUDE.md §2b). Every stage
this branch could affect is green: decisions-log, lint, l0, render-golden, l1,
and approval-policy (which evaluated for real on the pull_request event and
passed — no sensitive path touched). The one failure is `gitea-settings`:

    required_approvals: DECLARED 1 but LIVE is 0
    Gitea has DRIFTED from infra/gitea/expected-branch-protection.json

Every other declared setting matched, and one runner is online. So live `main`
currently requires ZERO approvals while the declaration says 1, and because
.gitea/workflows/gate.yml sets REQUIRE_GITEA_SETTINGS=true, that drift blocks
EVERY PR in the repo, not only this one. The check that would notice approvals
are unenforced is the check that is failing.

Not touched here: infra/gitea/** is a sensitive path (non-author approval
required) and outside this branch's documentation-only scope. Re-asserting the
protection or changing the declaration is an operator decision in its own PR;
local branch fix/admin-approval-override (616a497) looks like that work in
flight.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 01:01:13 -05:00
jakman85andClaude Opus 5 1677dff568 fix(runbooks): Plex hw transcoding WORKS — the dashboard was never the proof
gate / gate (pull_request) Failing after 1m46s
gate / conformance (pull_request) Has been skipped
Hardware transcoding is confirmed working on prod (2026-10-02): VAAPI for both
decode and encode on /dev/dri/renderD128, with zero-copy, measured in
`Plex Media Server.log` in the running pod. The device-plugin mechanism from #37
works end to end.

`runbooks/plex-quicksync.md` said the Plex dashboard showing `Transcode (hw)` was
"the *only* proof available, and nothing else will ever tell you". That is false:
Dashboard -> Activity showed nothing while the feature was fully working, so the
page's authoritative check was silent on a healthy system and sent the operator
hunting a fault that did not exist.

Three inference errors corrected, two kept as named false leads:

1. The LOG is the primary check, not the dashboard. The grep ships in its
   REDACTING form -- that log carries API URLs with `X-Plex-Token` (CLAUDE.md §6).
   Also recorded: `Plex Transcoder Statistics.log` is the WRONG file (XML
   <Variant/> records, no ffmpeg output), and why `grep -a` is required. The
   dashboard is kept but demoted to best-effort, with its silence stated to prove
   nothing.
2. An fd snapshot is not evidence. A /proc/*/fd sweep found no holder of
   renderD128 while hardware transcoding was active -- sessions are short and the
   scan landed in a gap. The reliable process-level check is the transcoder's
   argv (-hwaccel, -hwaccel_device, vaapi, renderD128).
3. A missing preference key means nothing. `HardwareAcceleratedCodecs` was absent
   from all 27 attributes with the toggle on and working, while
   `HardwareDevicePath` was written and the log said "hardware transcoding:
   enabled". Only `="0"` is conclusive; the log settles it.

Also stale and now fixed: GID 992 is a hostPath-era number. Under the plugin the
injected device is root:root, so the image's hook resolves GID 0 and adds `plex`
to the root group (`Groups: 0 100 1000`, `groups=1000(plex),0(root),100(users)`).
Marked as an inherent property of the plugin's DeviceSpec, not a defect, and
explicitly not a reason to reintroduce supplementalGroups (still a no-op --
s6-setuidgid rebuilds the group list from /etc/group). Added a per-title fallback
entry (UHD 630 has no AV1 decode), labelled a HYPOTHESIS: the title that fell
back was never checked. `runbooks/README.md`'s index entry was stale the same two
ways and is corrected.

Unchanged on purpose: the "STOP HERE IF PLEX IS Pending" section, the escape
hatch, and the no-alert-is-possible reasoning (no metricsEndpoint, no
ServiceMonitor, nothing a rule could evaluate).

Documentation only -- no catalog/, schemas/, apps/ or platform/ change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-02 00:49:53 -05:00
13 changed files with 668 additions and 104 deletions

No files matched your search

@@ -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,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,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.
@@ -306,19 +306,27 @@ node_baseline_dns_servers:
# 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.
#
# THIS USED TO BE BROKEN, AND THE 2026-09-04 SUBDOMAIN SPLIT FIXED IT. While prod was
# authored to serve dev's hostnames, a prod node resolving keycloak.home.keithjackson.dev
# publicly got DEV's Gateway VIP (192.168.1.53) — the wrong cluster, on the wrong LAN, so
# kubectl OIDC SSO on prod could not work at all. Now prod's issuer is
# keycloak.prod.home.keithjackson.dev, and the public record for that name is published by
# PROD's own external-dns pointing at PROD's Gateway VIP (192.168.2.20). The public answer
# and the split-horizon answer therefore AGREE, and no /etc/hosts pin is needed.
# THE PUBLIC ANSWER IS NOW THE WAN, SO THE ISSUER IS PINNED (2026-10-03). Since
# platform/gateway/gateway.yaml set an external-dns `target`, every public name is a CNAME
# to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from the
# LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
# k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18). The apiserver would then
# be unable to fetch its issuer's discovery/JWKS, so kubectl OIDC SSO would fail on the
# 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
# 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.
# The cert-based admin kubeconfig never depends on any of this. It is the break-glass path.
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) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
@@ -242,3 +242,9 @@ node_baseline_dns_search:
node_baseline_dns_options:
- timeout: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 %}
when: node_baseline_dns_mechanism == 'resolv-conf-file'
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]
+24
View File
@@ -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
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
Cloudflare API token referenced **by name** (`cloudflare-api-token`, shared with cert-manager),
+87
View File
@@ -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
+21 -30
View File
@@ -81,38 +81,29 @@ spec:
# 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.
# ---------------------------------------------------------------------------------------------
# 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
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
# this change, for two independent reasons:
# Until 2026-10-03 every gateway-sourced record answered with the Gateway VIP (192.168.2.20,
# RFC1918), so nothing published here was reachable from the internet. Two facts constrained the
# fix, and both still hold:
# * `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
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
# certificate. Whether prod's public services belong behind it, behind a new forward, or
# are not meant to be public at all is an operator decision. Breaking five services' DNS
# on a guess is worse than leaving a known-wrong-but-stable state.
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
# no per-service target override while every public service shares the `shared` Gateway.
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
# 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.
# The operator's answers: (a) yes, ALL of them — the five `exposure: public` apps AND the admin
# UIs (auth, crafty, grafana, headlamp, the apex), which sit behind Keycloak / oauth2-proxy;
# (b) the address is kept current IN-CLUSTER. Implementation:
# * platform/gateway/gateway.yaml sets `target: wan.${BASE_DOMAIN}` on the shared Gateway, so
# each published name becomes a CNAME to that one hostname;
# * platform/externaldns/ddns.yaml (favonia/cloudflare-ddns) keeps `wan.${BASE_DOMAIN}` an A
# record at the current WAN IP. That record has no external-dns TXT registry entry, so this
# instance never owns or garbage-collects it.
# Internal services are unchanged: their routes carry `controller: none` (above), which is
# checked BEFORE the target is read, so they still publish nothing. The `service` source (the L4
# LoadBalancers crafty-mc, unifi) does not read the Gateway's annotation and still publishes its
# LB VIP. Whether those should go public is a separate, still-open question.
# ---------------------------------------------------------------------------------------------
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
#
+2
View File
@@ -8,3 +8,5 @@ resources:
# SOPS-encrypted Cloudflare token (env CF_API_TOKEN) — SAME token as cert-manager, second copy in
# the external-dns namespace. Flux decrypts at reconcile.
- cloudflare-api-token.sops.yaml
# DDNS for `wan.${BASE_DOMAIN}` — the CNAME target every Gateway-published record points at.
- ddns.yaml
+17
View File
@@ -7,6 +7,23 @@ kind: Gateway
metadata:
name: shared
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:
gatewayClassName: cilium
listeners:
+12 -8
View File
@@ -153,14 +153,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`:
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.
- **`plex-quicksync`** — hardware transcoding on the host iGPU. GitOps puts `/dev/dri` in the pod
(`hardware.gpu: intel-quicksync`); **a human still has to tick Settings → Transcoder → "Use hardware
acceleration when available"**, and no environment variable can do it (the image supports no
`PLEX_PREFERENCE*`, verified). The page names the real preference key (`HardwareAcceleratedCodecs`,
read out of the binary), says which widely-cited key **does not exist**, and is explicit that the
**only** proof is `Transcode (hw)` on the Plex dashboard — Plex renders no ServiceMonitor, so no
alert could ever fire. Also: the device-cgroup failure to expect first and its device-plugin
fallback, and why a `:latest` pull can silently break it.
- **`plex-quicksync`** — hardware transcoding on the host iGPU. **Confirmed working 2026-10-02**
(VAAPI decode *and* encode, zero-copy). GitOps requests `gpu.intel.com/i915` on the pod
(`hardware.gpu: intel-quicksync`, satisfied by the `intel-gpu-plugin` DaemonSet — a `/dev/dri`
hostPath was measured not to work: device-cgroup `EPERM`); **a human still has to tick Settings →
Transcoder → "Use hardware acceleration when available"**, and no environment variable can do it
(the image supports no `PLEX_PREFERENCE*`, verified). **The proof is `Plex Media Server.log`'s
`final decoder: / final encoder:` line, not the Plex dashboard** — the dashboard showed nothing
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
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
+194 -55
View File
@@ -1,5 +1,10 @@
# 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**,
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
@@ -33,7 +38,7 @@ is in git**:
| Half | Who does it | State |
|---|---|---|
| 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
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
@@ -79,8 +84,8 @@ also the only thing that produces the cgroup allow rule. There is no graceful-de
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), a green gate only proves the **manifest
renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
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
@@ -172,17 +177,28 @@ cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with
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 \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \
"/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,
and the remaining question is whether it *works* — go to "How to verify".
| Reading | What it actually means |
|---|---|
| `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
@@ -191,8 +207,11 @@ Plex web UI → **Settings → Transcoder → "Use hardware acceleration when av
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
recall and not from a forum post. Real neighbouring keys from the same binary, so you can recognise
them and not invent them:
recall and not from a forum post. **But do not expect to see it in `Preferences.xml` after ticking
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 |
|---|---|
@@ -209,42 +228,121 @@ 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
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
lower quality explicitly), then read Plex's own **Dashboard → Activity**:
**Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)`
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.**
- plain **`Transcode`** ⇒ software. The feature is not working, whatever the manifest says.
### The primary check — run this one
**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** —
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.
```
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \
grep -aiE "hardware transcoding: (final|using)" \
"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \
| sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5'
```
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 \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh'
```
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions.
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.
then, inside the pod:
```
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
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.
First-command #4 returns `ABSENT` or `="0"`. Remedy: the manual step above. Check this **before**
investigating anything technical.
1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was
ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset
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. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
first-command #3 shows `renderD128` present and correctly owned, and the open returns
@@ -276,13 +374,27 @@ When the dashboard still says plain `Transcode`:
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.
5. **The session was never transcoding.** Direct Play / Direct Stream never touches the transcoder, so
`Transcode (hw)` cannot appear. Confirm the session actually says `Transcode` before treating it as
a hardware failure.
5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder,
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
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
Coffee Lake's UHD 630 has no engine for. Plex falls back to software *per stream*. Symptom: some
sessions show `(hw)` and others do not. Not a fault.
6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed
diagnosis.** The symptom that points here is fallback on **particular titles** while other
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
@@ -360,19 +472,30 @@ infra/devcontainer/dev.sh run bash -c \
```
`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`
needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that
hook exists.
described here: the hook reads the injected device's **own** ownership, which under the device plugin
is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). Gone or renamed ⇒ stop and
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
- **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
`cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into its
group *inside* the container, and the service then execs via `s6-setuidgid plex`, which **replaces**
the process group list from `/etc/group` rather than appending — `Plex Media Server` runs with
`Groups: 100 1000`, root's gid `0` (which PID 1 does hold) gone. Anything Kubernetes supplies is
**inert**, and it would mislead the next person. The image's `cont-init.d` hook stats each
`/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside
the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group
list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore
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
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.
@@ -390,7 +513,8 @@ hook exists.
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
lose the DB.
- **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2
confirmed) *under the current mechanism*. The next step is a platform change in
confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02
(`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
@@ -413,10 +537,25 @@ Recorded so they are findable, and so nobody assumes they were overlooked:
---
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…`
(`plexmediaserver 1.43.4.10903`). The `EPERM` open measurements, the `Groups: 44 100 992 1000`
reading and the `gpu.intel.com/i915: 1` allocatable/DaemonSet readings were taken **2026-10-01**.
Hardware transcoding has **still not been observed working**: the open test has not been re-run under
the device-plugin mechanism (it cannot be, until that merges and Flux reconciles), and the Plex-side
toggle is still unset.
**Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From
`Plex Media Server.log` in the running pod: API `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`. 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.