Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e6ef145f | ||
|
|
c163deaef9 | ||
|
|
642a7752d8 | ||
|
|
989649e64a | ||
|
|
21c88b0dc7 | ||
|
|
b156ee6cfa | ||
|
|
0aa1a0a78a | ||
|
|
422834d4e2 | ||
|
|
1f4552d409 | ||
|
|
c95b1c7121 | ||
|
|
ed6afc3d3f | ||
|
|
254985baa6 | ||
|
|
dce20b7be6 | ||
|
|
9a69f6906a |
No files matched your search
@@ -244,6 +244,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+22
-1
@@ -73,7 +73,7 @@ spec:
|
||||
- name: FTLCONF_dns_rateLimit_count
|
||||
value: '0'
|
||||
- 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:
|
||||
@@ -232,6 +232,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -137,6 +137,20 @@ spec:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
- name: ADVERTISE_IP
|
||||
value: http://plex-vip.${BASE_DOMAIN}: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
|
||||
@@ -146,6 +160,14 @@ spec:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
lifecycle:
|
||||
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:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
|
||||
@@ -319,6 +319,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -347,6 +347,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -345,6 +345,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+10
-1
@@ -134,8 +134,17 @@ env:
|
||||
# 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"
|
||||
# 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
|
||||
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.
|
||||
|
||||
+239
-8
@@ -72,14 +72,245 @@ env:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
||||
# 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
|
||||
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
|
||||
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
|
||||
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
|
||||
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
|
||||
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
|
||||
# ADVERTISE_IP ("Custom server access URLs") — the ONE hostname this server publishes to plex.tv,
|
||||
# serving BOTH local and remote clients by split-horizon DNS (operator design, 2026-10-04):
|
||||
# LAN -> PiHole answers plex-vip.${BASE_DOMAIN} with ${PLEX_L4_VIP} (the tcp/32400 LoadBalancer
|
||||
# in apps/wave-0/plex-remote.yaml). See catalog/pihole.yaml's split-horizon line, which
|
||||
# needs a per-host override because the zone wildcard otherwise points at the GATEWAY
|
||||
# VIP — measured 2026-10-04: plex-vip.${BASE_DOMAIN} resolved to the Gateway, where
|
||||
# nothing listens on 32400.
|
||||
# PUBLIC -> Cloudflare answers with the WAN address, NATed to the same VIP (off-repo).
|
||||
#
|
||||
# WHY A HOSTNAME AND NOT THE `plex.direct` FORM THIS BRIEFLY USED. The first fix for the
|
||||
# 2026-10-04 outage advertised `https://<dashed-ip>.<serverhash>.plex.direct:32400`, which works
|
||||
# (and is Plex's own mechanism), but it hard-codes BOTH the VIP and the server's certificate hash
|
||||
# into a cluster-var — two values nothing could keep in sync, with no alert if either drifted. A
|
||||
# hostname in our own zone removes that whole class of problem: ${BASE_DOMAIN} is already a knob,
|
||||
# the VIP lives only in PiHole's answer, and `PLEX_ADVERTISE_URL` is deleted outright.
|
||||
#
|
||||
# THIS IS `https`, WHICH ONLY WORKS BECAUSE WE HAND PLEX OUR OWN CERTIFICATE. Plex serves 32400
|
||||
# with its `*.<serverhash>.plex.direct` cert, which does NOT cover this name — advertising
|
||||
# `https://plex-vip...` against the stock cert reproduces the exact failure that caused the
|
||||
# outage (measured: a storm of `CERT: incomplete TLS handshake ... certificate unknown` from real
|
||||
# clients, and `curl` without `-k` failing to connect). The `lifecycle.postStart` hook below
|
||||
# installs the Let's Encrypt cert for this name and points Plex at it, so the advertised URL and
|
||||
# the served certificate agree. Do not change this to a bare IP, and do not drop the hook and
|
||||
# leave the `https` here.
|
||||
#
|
||||
# (A SAN match is not what makes Plex *work* — `httpsRequired=0` and `secureConnections` default
|
||||
# to Preferred, so clients can fall back to cleartext. It is what keeps the remote leg ENCRYPTED
|
||||
# instead of silently downgrading traffic that crosses the internet.)
|
||||
#
|
||||
# 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, and its `/.firstRunComplete` marker is 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. Hand-typed custom URLs are what broke this service; they are now
|
||||
# overwritten on every start.
|
||||
#
|
||||
# SCHEME IS `http` AND THAT IS TEMPORARY, 2026-10-04. It was `https`, which requires Plex to serve
|
||||
# a certificate for this name — and the Certificate that would have supplied one is NOT APPLIED
|
||||
# (see apps/wave-0/plex-cert.yaml's header). Advertising `https` without it reproduces the
|
||||
# 2026-10-04 outage exactly: Plex answers 32400 with its `*.<serverhash>.plex.direct` cert, every
|
||||
# TLS-validating client rejects the hostname, and plex.tv can publish nothing. `http` works TODAY
|
||||
# and is MEASURED to: PiHole answers plex-vip.${BASE_DOMAIN} with ${PLEX_L4_VIP} (verified live
|
||||
# against pihole-0, 2026-10-04) and Plex serves 32400 there.
|
||||
#
|
||||
# THE COST, STATED: the remote leg is unencrypted. That is tolerable only because remote access
|
||||
# does not work yet at all (the WAN tcp/32400 forward is still absent), so today this carries LAN
|
||||
# traffic only. RESTORE `https` IN THE SAME COMMIT that re-applies the Certificate — the two are
|
||||
# one change, and splitting them is what broke this.
|
||||
#
|
||||
# HOW TO GET `https` BACK. The Certificate that would serve this name was applied on 2026-10-04
|
||||
# and STALLED THE ENTIRE APP TIER; the file was DELETED rather than merely unregistered, because
|
||||
# `tls-lifecycle-automated` (R-TLS-02) fails any Certificate left in the tree that no Flux path
|
||||
# reconciles. Full post-mortem: decisions/2026-10-04-plex-unblock-app-tier.md. Three things must
|
||||
# land TOGETHER:
|
||||
# 1. EXEMPT cert-manager FROM `secret-provenance`. Its temporary private-key Secrets
|
||||
# (`plex-vip-<random>`) are denied by `secrets-must-be-flux-managed`, which matches every
|
||||
# Secret in a namespace labelled `homelab.keithjackson.dev/app-namespace: true` — `plex` HAS
|
||||
# that label, `gateway` (where the working wildcard cert lives) does NOT, which is why this
|
||||
# never bit the existing Certificate. `secretTemplate` cannot fix it: those Secrets come from
|
||||
# the keymanager controller, not the Certificate's template. Narrow fix: an
|
||||
# `exclude.any[].subjects` entry for ServiceAccount `cert-manager`/`cert-manager` (confirmed
|
||||
# live as the controller's SA). `platform/kyverno/**` is a SENSITIVE path — non-author approval.
|
||||
# 2. GIVE THE CERTIFICATE A NON-GATING HOME. `apps-wave-0` is `wait: true`, so Flux health-checks
|
||||
# the Certificate OBJECT and a pending issuance stalls the whole tier
|
||||
# (`timeout waiting for: [Certificate/plex/plex-vip status: 'InProgress']`). `optional: true`
|
||||
# on the env refs below protects the POD, not the Kustomization — that distinction is the
|
||||
# whole bug. Use a separate Kustomization with `wait: false`, the shape
|
||||
# clusters/base/backup.yaml already uses for exactly this reason.
|
||||
# 3. Flip this value back to `https`.
|
||||
- name: ADVERTISE_IP
|
||||
value: http://plex-vip.${BASE_DOMAIN}: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).
|
||||
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
|
||||
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||
|
||||
+47
-5
@@ -1541,12 +1541,54 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
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.
|
||||
|
||||
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
|
||||
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
|
||||
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
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")
|
||||
if not ep or not s["observability"]["metrics"]:
|
||||
obs = s["observability"]
|
||||
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 {
|
||||
"apiVersion": "cilium.io/v2",
|
||||
@@ -1564,7 +1606,7 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
|
||||
],
|
||||
"toPorts": [
|
||||
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
|
||||
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
|
||||
],
|
||||
}
|
||||
],
|
||||
|
||||
@@ -101,7 +101,7 @@ data:
|
||||
# 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
|
||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
|
||||
# plex-remote: PLEX_L4_VIP),
|
||||
# plex-remote + pihole: PLEX_L4_VIP),
|
||||
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||
# 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.
|
||||
@@ -189,6 +189,13 @@ data:
|
||||
# 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
|
||||
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
||||
|
||||
@@ -171,6 +171,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+21
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
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
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -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,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,150 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
## 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,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,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}`.
|
||||
@@ -45,17 +45,34 @@ hubble:
|
||||
enabled: true
|
||||
ui:
|
||||
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
|
||||
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
|
||||
l2announcements:
|
||||
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:
|
||||
autoMount:
|
||||
enabled: false
|
||||
hostRoot: /run/cilium/cgroupv2
|
||||
hostRoot: /sys/fs/cgroup
|
||||
|
||||
# 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
|
||||
|
||||
@@ -101,19 +101,92 @@ spec:
|
||||
enabled: true
|
||||
ui:
|
||||
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) ---
|
||||
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
||||
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
||||
l2announcements:
|
||||
enabled: true
|
||||
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
|
||||
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
|
||||
# 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.
|
||||
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
|
||||
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
|
||||
#
|
||||
# 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:
|
||||
autoMount:
|
||||
enabled: false
|
||||
hostRoot: /run/cilium/cgroupv2
|
||||
hostRoot: /sys/fs/cgroup
|
||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||
|
||||
@@ -38,14 +38,28 @@ hubble:
|
||||
enabled: true
|
||||
ui:
|
||||
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)
|
||||
l2announcements:
|
||||
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:
|
||||
autoMount:
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||
"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.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 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.mdLine 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",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "source"],
|
||||
@@ -69,7 +69,8 @@
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"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": {
|
||||
@@ -78,7 +79,8 @@
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user