Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5 81f627167d docs(obs): record that the gate-sidecar scrape works — four places still said it did not
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
PR #49 (merged 21c88b0) fixed the blocked scrape and it is verified working on prod
2026-10-05: all 6 targets up == 1, health=up, lastError='', lastScrapeDuration 0.001-0.006s
against 10.0s and "context deadline exceeded" before. oauth2_proxy_* is in the TSDB for
these namespaces for the first time (5 metric names, 148 series).

Four places in the tree still asserted the pre-fix state as current, in the present tense,
in the files a reader consults first:

  platform/oauth2-proxy/podmonitor.yaml  "READ THIS BEFORE BELIEVING THE TARGETS ARE UP:
    THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY" and "SO THE TARGETS BELOW WILL READ
    up == 0 UNTIL AN INGRESS ALLOWANCE LANDS". The first actively instructs the reader to
    disbelieve a correct up == 1. Now a STATUS block with the post-fix numbers, with the
    diagnosis kept below it under an explicit HISTORY heading because it is why the fix is
    shaped the way it is.
  catalog/render.py:159  "THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED".
  catalog/render.py:1365  the same claim on the sidecar arg.
  platform/oauth2-proxy/README.md  its subsections are dated and so are honest records, but
    a reader hits the stale body long before any correction. A SUPERSEDED pointer now sits
    under the heading and an Update 2026-10-05 section carries the before/after.

The "not scraped" claims are corrected; the "a flag alone is not enough" warning is KEPT
deliberately, because that part is still true and "flag without ingress allowance" is
exactly what caused the 63.7h outage.

Two substantive errors fixed while there, not just tense. The README's "three things are
needed" listed a Service port and a ServiceMonitor — wrong, a PodMonitor targets pod ports
directly, which is why no Service exposes 44180 and the renderer needed no Service change;
following it would have sent someone to edit Service rendering for nothing. And
podmonitor.yaml deferred the fix to "the catalog/renderer, in flight on another branch" —
no such branch existed; it now names r_monitoring_ingress_policy, with a new CRAFTY section
explaining the one namespace this file cannot help.

Comments only in render.py, so the rendered output is byte-identical: no apps/ file and no
golden changes, and therefore no sensitive path and no human approval needed — the opposite
of the PR this documents.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 21:18:33 -05:00
jakman85 4386d1453b Merge pull request 'fix(plex): advertise a resolver-independent plex.direct name again (LAN-wide outage)' (#52) from fix/plex-resolver-independent-advertise into main
gate / gate (push) Successful in 1m48s
gate / conformance (push) Has been skipped
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
Reviewed-on: #52
2026-10-06 02:06:00 +00:00
jakman85andClaude Opus 5 874c6703f0 fix(plex): advertise a resolver-independent plex.direct name again (LAN-wide outage)
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
I took Plex down for the whole LAN. ADVERTISE_IP was
http://plex-vip.${BASE_DOMAIN}:32400 -- a name that exists ONLY in PiHole's
split-horizon answers and in no public zone.

A Plex client resolves the advertised host ITSELF, with whatever resolver it
uses, and on a real LAN that is frequently not PiHole: browsers ship DoH on by
default, phones have "private DNS", guest/IoT devices take the router's DNS.
Those clients could not resolve the name, the router answered the failed lookup
with ITSELF, and they all landed on its web UI -- hence the %D certificate.

Measured 2026-10-05:
  192.168.2.1:443      -> C=ZZ, O=%D342e7e6b, CN=%D   (router, LAN side)
  136.51.115.252:443   -> the same %D cert            (router, WAN side)
  pihole-0 AND pihole-1 -> plex -> .20, plex-vip -> .24   (both CORRECT)
  gateway .20:443      -> CN=*.home.keithjackson.dev, valid
  http://192.168.2.24:32400/identity -> 200
  Plex's published connections: all port 32400, nothing on 443

The DNS was right; the DEPENDENCY on it was the bug. And since nothing Plex
advertises listens on 443, the clients had left the Plex path entirely.

Fix: <dashed-ip>.<serverhash>.plex.direct, which is RESOLVER-INDEPENDENT -- a
public zone returning the private address embedded in the label, so every
resolver (PiHole, the router, 1.1.1.1, a DoH browser) answers it with
192.168.2.24. No split-horizon entry, no Cloudflare record and no certificate
work needed: the wildcard cert Plex already serves on 32400 covers that name.
Re-verified today with full validation (no curl -k): 200, TLS verified.

The nicer hostname cannot be made correct for clients that bypass PiHole -- a
public record resolves to the WAN address and there is no NAT hairpin, so it is
right only for PiHole users and wrong for the rest. There is no configuration of
it that is right for both. PiHole's plex-vip answer is KEPT; it is just not what
Plex advertises.

Cost restated, not buried: the value embeds the VIP and the cert hash as a
literal and nothing checks they still agree. Accepted because an unresolvable
name is a worse failure -- silent, LAN-wide, and it points users at the router.

UNVERIFIED: unreconciled, and the client-side resolver path is INFERRED -- my
public-resolver probe was broken (NXDOMAIN even for a name that resolves) so I
discarded it. The chain is the only explanation consistent with the
measurements, but the NXDOMAIN-hijack step was not observed directly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 20:58:55 -05:00
jakman85 bc7095677b Merge pull request 'fix(plex): unblock the app tier — drop the Certificate that stalled apps-wave-0, advertise http' (#51) from fix/plex-unblock-app-tier into main
gate / gate (push) Successful in 1m47s
gate / conformance (push) Has been skipped
Reviewed-on: #51
2026-10-05 13:53:21 +00:00
jakman85andClaude Opus 5 a0e6ef145f fix(plex): unblock the app tier — drop the Certificate that stalled apps-wave-0, advertise http
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
I broke the app tier with #48. Two faults, both mine, both measured after 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, which matches every Secret in
   a namespace labelled homelab.keithjackson.dev/app-namespace=true and excludes
   only those labelled secret-managed-by=controller or carrying a Flux label.
   `plex` HAS that label; `gateway` -- 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. secretTemplate cannot fix it: those
   Secrets come from the keymanager controller, not the Certificate template. So
   no CertificateRequest, Order or Challenge was ever created.

2. FLUX HEALTH-CHECKS THE CERTIFICATE. `optional: true` did its job -- the pod
   rolled and ran fine without the Secret -- but apps-wave-0 is wait:true, so
   Flux waits on the Certificate OBJECT:
     timeout waiting for: [Certificate/plex/plex-vip status: 'InProgress']
   apps-wave-0 never went Ready; wave-1/-2 and everything downstream of
   platform-config sat in DependencyNotReady behind it. I guarded the pod and
   missed the object. That distinction is the whole bug.

Third, independent: the advertised URL was https with no servable certificate --
Plex answers 32400 with its *.plex.direct cert, which cannot cover plex-vip --
the exact failure mode of the outage earlier the same day.

So: delete the Certificate (pruning it clears the health gate) and advertise
http://plex-vip:32400, which is MEASURED to work today -- PiHole answers the
name with the Plex VIP (verified against pihole-0) and Plex serves 32400 there.
http is explicitly temporary and only tolerable because remote access does not
work yet; the restore condition is written at the call site.

DELETED, not merely unregistered: my first attempt kept the file as "tracked but
not onboarded" and the gate rejected it -- tls-lifecycle-automated (R-TLS-02)
fails any Certificate no Flux path reconciles. Legal for a Deployment, not for a
TLS object. The re-enable recipe (Kyverno exemption + a wait:false Kustomization
+ the https flip, as ONE change) is recorded in catalog/plex.yaml.

Kept and verified working from #48: the hostname design, PiHole's split-horizon
answer, and the postStart hook -- LanNetworksBandwidth is now set live.

Gate: rc 3, all substantive stages pass (first attempt was rc 1 BLOCKED).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 17:10:25 -05:00
jakman85 c163deaef9 Merge pull request 'feat(plex): one advertised hostname for local+remote, its own LE cert, and a LAN definition so LAN clients stop reading as remote' (#48) from feat/plex-vip-hostname-and-lan-networks into main
gate / gate (push) Successful in 1m48s
gate / conformance (push) Has been skipped
Reviewed-on: #48
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 21:47:32 +00:00
jakman85 642a7752d8 Merge pull request 'feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400' (#50) from feat/hubble-flow-export-plex into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #50
2026-10-04 21:47:20 +00:00
jakman85andClaude Opus 5 989649e64a feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
The requirement is log visibility only -- which address connected to Plex.

Plex's own log cannot show it. Every LoadBalancer Service here is
externalTrafficPolicy: Cluster, so the node holding the VIP's L2 lease SNATs
the client before forwarding to the backend pod. Measured: Plex had logged
3,214 requests from 10.0.1.x (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 via the Gateway.
Nothing on the router does this; there is no hairpin NAT. It is Cilium.

The address is not lost, only unrecorded. Cilium observes the flow at ingress,
BEFORE its own translation, so Hubble already has it:

  192.168.2.133 -> 10.0.0.135   real LAN client -> the Plex pod
  10.0.1.78     -> 10.0.0.135   same traffic post-SNAT, what Plex sees

What was missing is RETENTION: 4095/4095 flows at ~303/s is about thirteen
seconds of history, metrics disabled, no exporter configured.

So enable the static exporter, port-scoped, to the agent's stdout -- Alloy
already tails every pod into Loki, so this needs no new mount, no rotation
handling and no Alloy change.

  {namespace="kube-system", app="cilium"} |= "32400"

Scoped BY PORT, not namespace, on evidence: --to-namespace plex returned only
the post-SNAT flows; the record carrying the real client address did not match
it. A namespace filter would drop exactly what this exists to capture.

Rejected, with reasons: eTP:Local is incompatible with cilium 1.20's L2
announcer per upstream docs for this version ("may cause service IPs to be
announced on nodes without pods causing traffic drops") -- and the VIP is on
prod-3 while the pod is on prod-1, so it would black-hole 32400 immediately;
making it safe costs Plex its ability to reschedule. DSR: routing-mode is
vxlan, not native/geneve, and DSR returns bypass the ingress node, breaking
the router's stateful NAT on the remote path.

Also corrects the repo's own record: 2026-10-03 says the SNAT lands in
192.168.0.0/16 (the node's LAN address). It is 10.0.1.x, the cilium_host
inside the pod CIDR -- which is why LanNetworksBandwidth can never separate
local from remote while eTP is Cluster.

UNVERIFIED and stated in the handoff: nothing is reconciled; `filePath: stdout`
is taken from a verbatim upstream sentence but not confirmed against 1.20.1
source (a wrong notation is a silent no-op, with the file+Alloy fallback
documented); the port FlowFilter has no upstream example; and this change
ROLLS CILIUM -- prod-3 announces the Gateway, PiHole DNS and Plex VIPs, so a
lease move during the rollout could briefly interrupt LAN DNS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:36:12 -05:00
jakman85 21c88b0dc7 Merge pull request 'fix(obs): open 44180 to ns monitoring so the forward-auth gate sidecars are actually scrapable' (#49) from fix/oauth2-sidecar-metrics-scrape-netpol into main
gate / gate (push) Successful in 1m47s
gate / conformance (push) Has been skipped
Reviewed-on: #49
2026-10-04 21:33:28 +00:00
jakman85andClaude Opus 5 b156ee6cfa fix(obs): open 44180 to ns monitoring so the forward-auth gate sidecars are actually scrapable
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
platform/oauth2-proxy/podmonitor.yaml landed 2026-10-02 and discovered six targets that
have never once scraped. Measured on prod 2026-10-04: all six read health=down with
lastError "context deadline exceeded" and lastScrapeDuration exactly 10.0s — the
configured scrapeTimeout, i.e. packets DROPPED rather than refused.

Cause: every 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. Verified identical in all six of jackett, radarr, sonarr,
qbittorrent, pihole, crafty; zero namespaces had any -allow-monitoring-scrape object.

render.py already had the right idiom — r_monitoring_ingress_policy — but it returned None
unless the service declared observability.metricsEndpoint, and NO ServiceDefinition in this
catalog declares one. It was wired in at the call site and had never emitted a single
object on any branch: correct, and entirely latent.

It now also fires for the forward-auth gate sidecar's own metrics port, keyed on
_needs_oauth2_sidecar — deliberately the same condition the scrape keys on, since
podmonitor.yaml selects pods purely by the auth-mode label, so a target exists iff the
sidecar exists. Keying 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.

Rendered here rather than in platform/oauth2-proxy because that tier would have to name app
namespaces created by the sibling apps-wave-0/1 tiers — it would apply on a running cluster
and fail on a cold bootstrap. podmonitor.yaml's header records that objection; rendering
per-app dissolves it.

Five CNPs result, one per forward-auth app, each opening only 44180 to only ns monitoring.
apps/ re-rendered with the invocation it was generated by (--no-image-automation, verified
byte-identical beforehand); --emit-waves deliberately not run, so no wave kustomization moved.

Three L1 goldens are regenerated with it (+63 lines, -0): a renderer change that adds an
object necessarily drifts the goldens that pin the renderer's output. The first gate run
caught container-workload-render and egress-class-render as FAIL with exactly this CNP as
the drift. default-deny-baseline-render's golden regenerated byte-identical, which is the
evidence that the regen is faithful and not a rubber stamp.

That puts conformance/** in the diff, which is a sensitive path: this PR needs a non-author
approving review. The local gate cannot see that — offline it reports approval-policy green
(N/A).

Local toolbox gate: rc 3 UNDETERMINED, not 0. Every stage that ran is green and l1 went
FAIL -> pass with the goldens; the only not-run stage is gitea-settings (no
GITEA_SETTINGS_TOKEN). L2/L3 conformance is UNVERIFIED for all 8 touched requirements.

Not verified: that the scrape now succeeds. Nothing is applied until Flux reconciles main.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:05:44 -05:00
23 changed files with 1088 additions and 136 deletions

No files matched your search

+21
View File
@@ -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
-1
View File
@@ -18,7 +18,6 @@ resources:
- pihole-dns.yaml
- pihole-oauth2-proxy-credentials.sops.yaml
- pihole.yaml
- plex-cert.yaml
- plex-claim-secret.sops.yaml
- plex-remote.yaml
- plex.yaml
+21
View File
@@ -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
-66
View File
@@ -1,66 +0,0 @@
---
# =================================================================================================
# PLEX TLS — a Let's Encrypt certificate for the ONE hostname Plex advertises to plex.tv.
# =================================================================================================
# HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition
# and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same
# mechanism gitea-actions, pihole-dns and plex-remote use. It is hand-written for the same reason:
# `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has NO field for a
# cert-manager Certificate. (`ingressRoutes` gets TLS for free from the Gateway's wildcard; this is
# the opposite case — a certificate for a workload that terminates its OWN TLS on a raw L4 port.)
#
# WHY PLEX NEEDS ITS OWN CERTIFICATE AT ALL. Plex serves tcp/32400 directly (not through the L7
# Gateway — the operator chose the native path 2026-10-03, apps/wave-0/plex-remote.yaml) and it
# presents its own `*.<serverhash>.plex.direct` certificate there. That name cannot cover
# `plex-vip.${BASE_DOMAIN}`, the hostname we advertise, so without this the advertised `https://`
# URL fails hostname verification for every client. That is not hypothetical: it is the shape of
# the 2026-10-04 outage, where Plex's log carried a continuous storm of
# `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` from real LAN clients while
# plex.tv could publish nothing and the server vanished from the account.
#
# A SAN match is not what makes Plex FUNCTION — `httpsRequired=0` and `secureConnections` default
# to Preferred, so clients can fall back to cleartext. It is what keeps the REMOTE leg encrypted
# rather than silently downgrading traffic that crosses the internet.
#
# HOW IT REACHES PLEX. catalog/plex.yaml takes `tls.crt`/`tls.key` from this Secret as env
# (`optional: true` — see below), and its `lifecycle.postStart` hook converts the PEM into the
# PKCS#12 bundle Plex's `customCertificatePath` requires and sets the three `customCertificate*`
# prefs. The conversion is redone on EVERY pod start, so a cert-manager RENEWAL is picked up with
# no extra moving part and nothing to remember.
#
# ORDERING, AND WHY THE ENV REFS ARE OPTIONAL. This Certificate must live in the `plex` namespace
# for the Deployment to read its Secret, and that namespace is created by the rendered
# apps/wave-0/plex.yaml ITSELF — so this cannot be issued in an earlier tier (platform-config runs
# before the namespace exists) and lands in the SAME reconcile as the workload. On a cold bootstrap
# the Secret is therefore absent for as long as a DNS01 challenge takes. With hard env refs Plex
# would sit in CreateContainerConfigError until then, 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. Hence
# `optional: true` on both refs (schema 1.11.0, added for exactly this): no certificate means Plex
# still starts and serves on its own plex.direct cert, the hook logs that TLS was not installed,
# and the next start after issuance converges.
#
# ISSUER: ClusterIssuer `letsencrypt-prod` (platform/cert-manager/clusterissuers.yaml), the same
# issuer behind the Gateway's wildcard (platform/gateway/wildcard-certificate.yaml, Ready 28d on
# prod as measured 2026-10-04). A SINGLE name is requested rather than reusing the wildcard,
# because the wildcard's Secret lives in the `gateway` namespace and a Secret cannot be read across
# namespaces; requesting `*.${BASE_DOMAIN}` again here would also burn a duplicate-certificate slot
# against Let's Encrypt's weekly limit for no benefit. A single name is the least-privilege shape:
# this key signs for `plex-vip.${BASE_DOMAIN}` and nothing else.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: plex-vip
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
# The name Plex ADVERTISES and the name on this certificate must stay identical. They are two
# halves of one decision: catalog/plex.yaml's ADVERTISE_IP, the postStart hook's
# customCertificateDomain, this dnsNames entry, and catalog/pihole.yaml's split-horizon answer.
# Nothing enforces that they agree.
dnsNames:
- plex-vip.${BASE_DOMAIN}
secretName: plex-vip-tls
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
+1 -1
View File
@@ -138,7 +138,7 @@ spec:
name: plex-claim
key: PLEX_CLAIM
- name: ADVERTISE_IP
value: https://plex-vip.${BASE_DOMAIN}:32400
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
- name: PLEX_TLS_CRT
valueFrom:
secretKeyRef:
+21
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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
+46 -30
View File
@@ -72,43 +72,59 @@ env:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
# 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).
# ADVERTISE_IP ("Custom server access URLs") — the connection Plex publishes to plex.tv.
#
# 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.
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
# property is what this field keeps getting wrong, so it is the first thing to check before
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
# clients hard-code a public resolver.
#
# 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.
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
# the Plex path entirely and were talking to the router.
#
# (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.)
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
#
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
# configuration of that name that is right for both. `plex.direct` is right for both.
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
#
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
# listener with
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
# least fails loudly and only after someone changes the VIP.
#
# The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections"
# "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the
# PLEX_CLAIM/PlexOnlineToken guard, 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.
# PLEX_CLAIM/PlexOnlineToken guard, with its `/.firstRunComplete` marker on the container OVERLAY
# fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not a web
# form, owns the value.
- name: ADVERTISE_IP
value: https://plex-vip.${BASE_DOMAIN}:32400
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
# THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume.
# `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly
# `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this
+61 -29
View File
@@ -156,11 +156,17 @@ OAUTH2_COOKIE_REFRESH = "2m30s"
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
#
# THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED. A container port is not a
# Service port: these sidecars' Services expose the app port and :4180, and each namespace's
# default-deny baseline has `ingress: []`, so Prometheus can neither resolve nor reach this
# port until a Service port (or PodMonitor), a monitoring-ingress allow and a ServiceMonitor
# are added. Do not read this flag as "metrics are working".
# THE SCRAPE WORKS AS OF 2026-10-05 — but it took THREE independent pieces, and this flag is
# only the first. A container port is not a Service port, and each namespace's default-deny
# baseline denies all ingress, so the flag alone bought nothing for 63.7h:
# 1. this flag + a named `metrics` container port (PR #28)
# 2. a monitor that targets POD ports, not Service ports — platform/oauth2-proxy/podmonitor.yaml
# is a PodMonitor for exactly that reason (no Service exposes 44180)
# 3. an ingress allowance from ns monitoring (PR #49, r_monitoring_ingress_policy below)
# Verified on prod 2026-10-05: all 6 targets up == 1, scraping in 1-6ms.
# STILL do not read this flag alone as "metrics are working" — 1 without 3 is precisely the state
# that produced 5 x TargetDown for 63.7h. If you add a sidecar metrics port anywhere else, check
# that r_monitoring_ingress_policy covers it.
OAUTH2_PROXY_METRICS_PORT = 44180
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
@@ -1362,8 +1368,9 @@ def _oauth2_sidecar_container(s: dict) -> dict:
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
"--pass-access-token=true",
"--set-xauthrequest=true",
# Start the /metrics listener. Prerequisite only — not yet reachable by Prometheus;
# see OAUTH2_PROXY_METRICS_PORT above for exactly what is still missing.
# Start the /metrics listener. Scraped as of 2026-10-05, but only because
# r_monitoring_ingress_policy also opens this port to ns monitoring — the flag alone was
# inert for 63.7h. See OAUTH2_PROXY_METRICS_PORT above for the three pieces required.
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
]
secret = f"{s['name']}-oauth2-proxy"
@@ -1541,12 +1548,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 +1613,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]}
],
}
],
@@ -2167,23 +2216,6 @@ EXTRA_UNITS: dict = {
"plex-remote.yaml",
],
},
# Plex's own TLS certificate for the single hostname it advertises to plex.tv
# (apps/wave-0/plex-cert.yaml). `above: False` co-locates it in plex's OWN wave, which is the
# only placement available: the Certificate must land in the `plex` namespace, and that
# namespace is created by the rendered plex.yaml in this very wave, so it CANNOT be issued
# earlier. The workload tolerates the resulting gap by marking the env refs `optional` rather
# than by ordering -- see the header of plex-cert.yaml.
#
# Non-catalog because the ServiceDefinition schema has no field for a cert-manager Certificate
# (additionalProperties is false); `ingressRoutes` inherits the Gateway wildcard instead, which
# is the opposite case to a workload terminating its own TLS on a raw L4 port.
"plex-cert": {
"anchor": "plex",
"above": False,
"files": [
"plex-cert.yaml",
],
},
}
@@ -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
@@ -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,157 @@
---
date: 2026-10-04
branch: feat/hubble-flow-export-plex
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
---
## What changed
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
address connected to Plex. Not bandwidth metering, which does not matter here.
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
before forwarding to the backend pod:
| Measured 2026-10-04 | Value |
|---|---|
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
| `192.168.x` sources in Plex's log, ever | **0** |
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
because the backend pod is on a different node.
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
translation, so Hubble already holds the true source:
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
`{"source_port":["32400"]}`.
- **`platform/cilium/values.yaml`** (reference mirror) and
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
be a permanent Helm diff and an avoidable agent rollout.
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
`stdout` can be defined as `hubble-export-file-path`."* Query:
{namespace="kube-system", app="cilium"} |= "32400"
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
address did not match it, because at the point of observation the destination identity is not yet
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
exists to capture.
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
pinning both the pod and the announcement to one node and surrendering Plex's ability to
reschedule — a real availability loss to buy a log field. Same trap
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
logging.
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
only because metering is explicitly not a goal here.
## What I verified
Live prod, 2026-10-04, read-only.
- **Every measurement in the table above**, including the VIP-lease-to-node map
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
contains no `hubble-export-*` key at all.
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
the docs for the running version — not inferred from the crafty incident.
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
which is what rules DSR out.
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
stdout needs no Alloy change.
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
with `export.static` resolving to the intended object.
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two known environmental skips
(`gitea-settings` — no local `SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE`
unset). **Not green. Reported as rc 3.**
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
## What I did NOT verify
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
still has no `hubble-export-*` key. Every claim is about inputs.
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
and the chart default is a real filesystem path. If the literal string is not special-cased, the
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
`/var/run/cilium/hubble`.
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
but upstream gives **no port example** and I did not validate it against the running agent. A
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
nothing.
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
quiet moment.
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
by dropping a filter.
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
set actually matches (it depends on the cilium pods carrying
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
is not fed a post-translation view.
- **The parity convention has no automated check.** I kept all three files in sync by hand because
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
a future edit can still drift them silently.
@@ -0,0 +1,155 @@
---
date: 2026-10-04
branch: fix/oauth2-sidecar-metrics-scrape-netpol
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
---
> **Added 2026-10-05, after merge:** the top item under *What I did NOT verify* below — "that the
> scrape now succeeds" — **has since been verified on prod**. All 6 targets read `up == 1`. The
> original text is left exactly as written; the measurements are in
> [[2026-10-05-oauth2-sidecar-scrape-verified]].
## What changed
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
in at the call site and had **never emitted a single object on any branch** — correct, and
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
clause, so its packets were dropped.
Two deliberate choices, both recorded in the function's docstring:
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
function exists to prevent, moved one field over.
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
header records why that tier cannot host it: it would name app namespaces created by the
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
## What I verified
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
- **The cause, not a guess.** All 6 sidecar targets in pool
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
- **The listener is fine.** jackett's `oauth2-proxy` container carries
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
0 silences.
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
5 files changed, +21 lines each, nothing else touched.
- `render.py --selftest`: **PASS**.
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
render path was used, so no wave kustomization changed.
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
failing stage.** Read from the job log, not from the status tick:
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
[ ok ] stage decisions-log: pass
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[fail] stage approval-policy: FAIL
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
[skip] stage conformance: SKIPPED (not run)
sensitive paths touched:
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
which is the independent confirmation that the regenerated goldens are right rather than merely
self-consistent with my local run.
One local-reproduction note worth recording, because it wasted a step: running
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
that 401 as the CI failure; the job log is what settled it.
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
offline it evaluates N/A.
The golden update is not optional — a renderer change that adds an object necessarily drifts the
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
## What I did NOT verify
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
which demonstrably works for 4180 — so this is inference from a working sibling, not a
measurement of the new object.
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
at all** — so there is no port to open and this change cannot help it. Separate work.
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
run here either — the gate proves the L0/L1 offline subset only).
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
I did not identify. It does not affect this fix.
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
from 2026-10-01.
@@ -0,0 +1,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,90 @@
---
date: 2026-10-05
branch: docs/oauth2-sidecar-scrape-verified
title: The gate-sidecar scrape is verified working on prod — and four places in the tree still said it wasn't
---
## What changed
Documentation only. No manifest, no rendered output, no behaviour.
PR #49 merged as `21c88b0` and the fix works, but four places in the tree still asserted the
*pre-fix* state as current — in the present tense, in capitals, in the files a reader would consult
first. This is the stale-documentation failure mode this repo has been burned by twice already
(`git push origin main` in the runbooks, "append to DECISIONS-LOG.md" in the old handoffs), so:
- **`platform/oauth2-proxy/podmonitor.yaml`** — the banner read *"READ THIS BEFORE BELIEVING THE
TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY"* and *"SO THE TARGETS BELOW WILL
READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS"*. Both now false, and the first one actively
instructs the reader to disbelieve a correct `up == 1`. Replaced with a `STATUS: THE SCRAPE WORKS`
block carrying the post-fix numbers; the diagnosis is kept below it under an explicit `HISTORY`
heading, because it is why the fix is shaped the way it is. Added the drop fingerprint to
recognise if it regresses — `lastScrapeDuration` pinned at *exactly* the `scrapeTimeout` with
`context deadline exceeded`, versus 1–6 ms when healthy.
- **`catalog/render.py:159`** — *"THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED … Do not
read this flag as 'metrics are working'."* Rewritten to enumerate the three pieces a working
scrape needs and which PR landed each. The warning is **kept, deliberately**: the flag alone
really is insufficient, and "1 without 3" is exactly what caused the 63.7h outage. Only the
"not yet scraped" claim was false.
- **`catalog/render.py:1365`** — the sidecar arg comment, same correction, one line.
- **`platform/oauth2-proxy/README.md`** — its two observability subsections are *dated*
("measured 2026-09-30", "Update 2026-10-01"), so they are honest records rather than lies. But a
reader hits the stale body long before any correction, so a `> SUPERSEDED` pointer now sits
directly under the heading, and an `### Update 2026-10-05` section at the end carries the
before/after table, the TSDB series counts and the alert outcome.
Two substantive errors fixed while there, not just tense:
1. The README's *"To close the gap for an app, three things are needed"* listed **a Service port
and a ServiceMonitor**. Wrong — a PodMonitor targets pod ports directly, which is the whole
reason no Service exposes 44180 and the renderer needed no Service change. Following that
paragraph would have sent someone to edit `catalog/render.py`'s Service rendering for no reason.
2. `podmonitor.yaml` ended by deferring the fix to *"the catalog/renderer, which is in flight on
another branch"* — that branch does not exist and never did; the work happened in PR #49. It now
names `r_monitoring_ingress_policy` directly, and a new `CRAFTY` section explains why crafty is
the one namespace this file cannot help.
## What I verified
- **The fix itself, on prod 2026-10-05, read-only** (this is the evidence the docs now quote):
Flux applied `21c88b0` through the real path — `apps-wave-0` `lastAppliedRevision` at 21:37:01Z,
`apps-wave-1` at 21:37:46Z, in dependency order — and all five `<app>-allow-monitoring-scrape`
CNPs exist, each `ports=['44180']`, `from=[['monitoring']]`.
- **All 6 targets `up == 1`**, `health=up`, `lastError=''`, `lastScrapeDuration` 0.001–0.006s —
against 10.0s and `context deadline exceeded` before.
- **`oauth2_proxy_*` is in the TSDB for these namespaces for the first time:** 5 metric names,
148 series; `sum by (namespace) (oauth2_proxy_requests_total)` = qbittorrent 35072, radarr 23560,
sonarr 1284. (Queried with `count by (__name__)` plus a matcher, not label-values.)
- **Alerts:** all 5 `TargetDown` and 5 of 6 `OAuth2ProxyGateUnobserved` resolved; one series
remains, `OAuth2ProxyGateUnobserved{namespace="crafty"}`, firing.
- **The rendered output is byte-identical.** `catalog/render.py` changed only in comments, so this
branch touches no `apps/` file and no golden. Confirmed by `render-golden` and `l1` both passing
with no regeneration, and by `git status` showing nothing under `apps/`.
- **This branch touches no sensitive path**, unlike PR #49: `platform/oauth2-proxy/**`,
`catalog/**` and `decisions/**` are none of them in `infra/gitea/approval-policy.json`. So it
should merge on a green gate with no human approval — the opposite of the PR it documents, and
the reason the two were kept apart.
- `render.py --selftest`: PASS.
- Toolbox gate: see the handoff's own rule — reported below rather than claimed here.
## What I did NOT verify
- **I did not re-measure the 63.7h figure or the ~22 emails/day rate**; both are carried forward
from the 2026-10-04 measurements, now quoted inside the docs as historical. The 15-day
`query_range` that established "one episode, not flapping" was run once, on 2026-10-04.
- **`oauth2_proxy_requests_total` is still absent for jackett and pihole.** I assert in the README
that this is because the counter is not created until the gate serves its first request. That is
the documented behaviour of a Prometheus counter with no initialisation, and it is consistent
with the other three namespaces having traffic — but I did not force a request through jackett's
gate and watch the series appear. If it is still absent after real use, that is a finding.
- **The auth-churn rule is still unwritten.** The README now says it is "writable rather than
decorative"; that is a claim about the metrics being present, not a rule anyone has authored or
unit-tested. The thing the 2026-09-30 incident actually needed does not exist yet.
- **crafty remains unobserved.** I read its container spec on 2026-10-05 and confirmed no
`--metrics-address`; I did not attempt the fix, and nothing here changes its alert.
- **I did not audit the whole tree for other stale "metrics are not scraped" claims** beyond the
four sites a targeted grep found (`not yet reachable`, `UNTIL AN INGRESS`, `up == 0`,
`not reachable by Prometheus` and neighbours). `runbooks/oauth2-proxy-auth-failures.md` is
described in the README as "the by-hand substitute meanwhile" and I left it alone — the by-hand
log procedure still works and is not wrong, but whether it should now be rewritten against the
new metrics is unexamined.
@@ -0,0 +1,105 @@
---
date: 2026-10-05
branch: fix/plex-resolver-independent-advertise
title: Plex down LAN-wide — the advertised name existed only in PiHole, so clients that bypass it landed on the router's %D cert
---
## What changed
**I took Plex down for the whole LAN.** Reported symptom: every internal client showed a certificate
error with `%D` as the subject name and could not connect.
**Root cause: the advertised connection depended on our own resolver.** `ADVERTISE_IP` was
`http://plex-vip.${BASE_DOMAIN}:32400` — a name that exists ONLY in PiHole's split-horizon answers
and in no public zone. A Plex client resolves the advertised host *itself*, with whatever resolver it
happens to use, and on a real LAN that is frequently not PiHole: browsers ship DoH on by default,
phones have "private DNS", guest/IoT devices take the router's DNS. Those clients could not resolve
the name, the SOHO router answered the failed lookup with **itself**, and they all landed on the
router's web UI.
| Measured 2026-10-05 | Result |
|---|---|
| `192.168.2.1:443` (router, LAN side) | `C = ZZ, ST = Somewhere, O = %D342e7e6b, CN = %D` |
| `136.51.115.252:443` (router, WAN side) | the same `%D` cert |
| PiHole **pihole-0** | `plex -> 192.168.2.20`, `plex-vip -> 192.168.2.24` ✅ |
| PiHole **pihole-1** | identical ✅ (both behind the `.23` VIP) |
| Gateway `.20:443` cert | `CN = *.home.keithjackson.dev`, valid to 2026-12-12 ✅ |
| `http://192.168.2.24:32400/identity` | `200` ✅ |
| Plex's published connections | all port **32400** — nothing on 443 |
**The DNS was right; the dependency on it was the bug.** PiHole answered correctly from both
replicas the entire time. And because nothing Plex advertises listens on 443, the clients had plainly
left the Plex path altogether and were talking to the router.
**The fix: go back to `<dashed-ip>.<serverhash>.plex.direct`, which is RESOLVER-INDEPENDENT.** This
is Plex's own mechanism and the reason it exists — `plex.direct` is a public zone that returns the
private address embedded in the label, so *every* resolver (PiHole, the router, 1.1.1.1, a DoH
browser) answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no split-horizon
entry, no Cloudflare record and no certificate work, because the wildcard cert Plex already serves on
32400 covers exactly that name.
- **`catalog/plex.yaml`:** `ADVERTISE_IP` →
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`, with the
resolver-independence requirement written at the top of the block as the first thing to check
before anyone changes this field again.
- **PiHole's `plex-vip` answer is KEPT.** It is harmless, costs nothing, and is useful for reaching
the VIP by name from a LAN shell. It is simply not what Plex advertises.
**Why not keep the nicer hostname.** It cannot be made correct for clients that bypass PiHole: a
public record for it resolves to the WAN address, and with no NAT hairpin on this router a LAN client
sent there fails too. The name is right only for PiHole users and wrong for everyone else, and there
is no configuration of it that is right for both. `plex.direct` is right for both.
**The honest cost, restated rather than buried:** the value embeds both the VIP and the server's
certificate hash as a literal — envsubst cannot dash an IP and nothing in the tree can derive the
hash, so nothing checks that they still agree. That fragility is exactly why the hostname was tried.
It is accepted because an unresolvable name is a *worse* failure: silent, LAN-wide, and it points
users at their router.
## What I verified
Live prod, 2026-10-05, read-only.
- **Every row of the table above**, including the router's `%D` cert on both its LAN and WAN sides —
which is what identifies the router as what the clients were reaching.
- **THE FIX ITSELF, with full TLS validation (no `curl -k`)**: the plex.direct name resolves to
`192.168.2.24` and `/identity` returns **200, TLS verified**. Re-checked today, not carried over
from yesterday's measurement.
- **Both PiHole replicas are identical and correct**, with the same five `address=` lines live in
`/etc/pihole/dnsmasq.conf`, and both IPs present behind the `pihole-dns-tcp`/`-udp` endpoints. I
checked only pihole-0 at first and deliberately went back for pihole-1, because a split-brain pair
would have produced exactly this intermittent-looking symptom.
- **The cluster was never the problem:** all eight LoadBalancer VIPs still assigned, L2
announcements intact (prod-3 holds `.20`/`.23`/`.24`), `wildcard-home` Certificate `Ready=True`,
and PR #51 had reconciled (`customConnections="http://plex-vip.home.keithjackson.dev:32400"` was
live, confirming the bad value was in effect).
- **`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is still set**, so the postStart hook survived
the roll and keeps re-asserting.
- **The Cilium rollout from #50 STILL has not happened** — agents are unchanged since
2026-10-04T07:11, so the Hubble export is inactive and `filePath: stdout` remains unproven.
## What I did NOT verify
- **THE OUTCOME. Unreconciled.** No client has been observed connecting after this change. The
argument is that the advertised name now resolves for every resolver and serves a matching
certificate — both measured — but I have not seen a Plex client complete a connection, and I
cannot see the user's clients at all.
- **THE CLIENT-SIDE RESOLVER PATH IS INFERRED, NOT OBSERVED.** I could not query a public resolver
from here — my attempt returned NXDOMAIN even for a `plex.direct` name that certainly resolves, so
the probe was broken (egress policy or a missing tool) and I discarded it rather than report it.
The chain "client bypasses PiHole → NXDOMAIN → router answers with itself → `%D`" is the only
explanation consistent with all the measurements, but the NXDOMAIN-hijack step in particular is
deduced from the router serving `%D` on its LAN address, not from watching a lookup.
- **Which clients bypass PiHole, or how many.** If in fact every client uses PiHole, then my
explanation is wrong and something else sent them to the router — in which case the next thing to
check is whether the clients were using `plex.home.keithjackson.dev` (port 443, the Gateway) rather
than an advertised connection at all.
- **Whether plex.tv will re-publish promptly** after the value changes, and whether stale connection
lists are cached on clients. A client holding the old list may keep failing until it refreshes.
- **Mixed content.** The previous `http://` value would also have been blocked by the browser for
anyone using `https://app.plex.tv`; that is fixed incidentally by returning to `https`, but I did
not test it.
- **The Remote badge — the symptom that began this whole sequence — remains unconfirmed.**
`LanNetworksBandwidth` is set, but Plex still tags traffic `Allowed Network (WAN)`. Unchanged and
still unexplained.
- **`ManualPortMappingMode="1"` with an absent port** is still there, untouched.
@@ -45,6 +45,20 @@ 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.
+54
View File
@@ -101,6 +101,60 @@ 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.
+12
View File
@@ -38,6 +38,18 @@ 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
+56
View File
@@ -91,6 +91,10 @@ This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on
## Observability: what this component can and cannot see (measured 2026-09-30)
> **SUPERSEDED — read "Update 2026-10-05" at the end of this section first.** The gate sidecars
> **are** scraped now and the TSDB **does** hold `oauth2_proxy_*` for them. The two subsections
> below are kept as the dated record of how this was diagnosed, not as current state.
`servicemonitor.yaml` scrapes **this namespace's** deployment and nothing else. On prod that is
two targets, `job="oauth2-proxy"`, both `up=1`. The complete set of oauth2-proxy-specific series
in the TSDB is:
@@ -157,3 +161,55 @@ present `up` series. A discovered-but-failing target publishes `up` with value 0
would have cleared `OAuth2ProxyGateUnobserved` the moment the PodMonitor landed — reporting the gates
as observed while nothing was being scraped. The unit test pins that scenario; deleting the `== 1`
fails it and nothing else.
### Update 2026-10-05: the scrape works
PR #49 (merged `21c88b0`) added the missing third piece. `catalog/render.py`'s
`r_monitoring_ingress_policy` now also fires for the gate sidecar's metrics port, keyed on
`_needs_oauth2_sidecar`, rendering `<app>-allow-monitoring-scrape` per app: ns `monitoring` → that
app's `endpointSelector` → TCP 44180 only. Five objects, one each for jackett, pihole, qbittorrent,
radarr, sonarr.
Measured on prod 2026-10-05 after Flux applied it (`apps-wave-0` 21:37:01Z, `apps-wave-1`
21:37:46Z):
| | before | after |
|---|---|---|
| target health (6) | `down` | `up` |
| `lastError` | `context deadline exceeded` | `''` |
| `lastScrapeDuration` | **10.0s** — exactly the `scrapeTimeout` | **0.001–0.006s** |
| `up` | `0` × 6 | `1` × 6 |
count by (__name__) ({__name__=~"oauth2_proxy_.*",
namespace=~"jackett|radarr|sonarr|qbittorrent|pihole"})
oauth2_proxy_requests_in_flight => 6
oauth2_proxy_requests_total => 16
oauth2_proxy_response_duration_seconds_bucket => 108
oauth2_proxy_response_duration_seconds_sum => 9
oauth2_proxy_response_duration_seconds_count => 9
sum by (namespace) (oauth2_proxy_requests_total)
qbittorrent => 35072 radarr => 23560 sonarr => 1284
`oauth2_proxy_requests_total` is absent for jackett and pihole simply because that counter is not
created until the gate has served its first request.
**So the auth-churn rule is now writable rather than decorative** — sustained `code="302"` as a
share of `oauth2_proxy_requests_total`, held across a window longer than the session lifetime. That
is the rule the 2026-09-30 incident above could not be caught by. It is still unwritten.
**Alert outcome:** all 5 `TargetDown` resolved, and 5 of 6 `OAuth2ProxyGateUnobserved` with them.
At `repeat_interval: 12h` with `group_by: [alertname, namespace]`, those 11 groups had been
re-notifying at roughly **22 emails/day** since 2026-10-02; that drops to 2/day.
**`crafty` still fires, and that is correct.** It is a hand-maintained Deployment under
`platform/crafty`, outside the renderer, whose `oauth2-proxy` container has **no
`--metrics-address` at all** — no listener, so no port to open and nothing for the PodMonitor's
pod-label selector to match. Closing it needs the flag and a `metrics` containerPort on crafty's
own manifest first, then a hand-written allowance beside it. Open follow-up work.
**The paragraph above headed "To close the gap for an app" is superseded**: a *Service* port and a
ServiceMonitor are **not** required. A PodMonitor targets pod ports directly, which is why no
Service exposes 44180 and why the renderer needed no Service change. The three things actually
needed are `--metrics-address` + named container port, a PodMonitor, and the monitoring-ingress
allowance.
+43 -9
View File
@@ -27,10 +27,28 @@
# created by the apps-wave-0/1 tiers, which this tier does not and must not depend on.
#
# ============================================================================================
# READ THIS BEFORE BELIEVING THE TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY
# STATUS: THE SCRAPE WORKS. Verified on prod 2026-10-05 (PR #49 merged as 21c88b0).
# ============================================================================================
# This PodMonitor is NECESSARY AND NOT SUFFICIENT. The endpoint serves, and the path from ns
# monitoring is blocked. Both halves measured on prod 2026-10-01, read-only:
# All 6 targets in pool podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0 read `up == 1`,
# health=up, lastError='', lastScrapeDuration 0.001-0.006s. The TSDB holds oauth2_proxy_* for
# these namespaces for the first time: 5 metric names / 148 series, with live counters
# (oauth2_proxy_requests_total 35072 qbittorrent, 23560 radarr, 1284 sonarr). All 5 TargetDown
# and 5 of 6 OAuth2ProxyGateUnobserved resolved; only namespace="crafty" still fires, and that
# is correct — see CRAFTY below.
#
# The missing piece was an ingress allowance, and it now ships WITH EACH APP, rendered by
# catalog/render.py's r_monitoring_ingress_policy as `<app>-allow-monitoring-scrape`:
# ns monitoring -> the app's endpointSelector -> TCP 44180 only. See WHY NOT IN THIS DIRECTORY
# below for why it is rendered per-app instead of shipped from this tier.
#
# Keep the history below. It is why the fix is shaped the way it is, and `up == 1` in
# auth-visibility-alerts.yaml is load-bearing precisely because of it.
#
# --------------------------------------------------------------------------------------------
# HISTORY: 2026-10-02 -> 2026-10-05, THE SCRAPE WAS DENIED BY NETWORKPOLICY FOR 63.7h
# --------------------------------------------------------------------------------------------
# This PodMonitor was NECESSARY AND NOT SUFFICIENT: the endpoint served, and the path from ns
# monitoring was blocked. Both halves measured on prod 2026-10-01, read-only:
#
# THE ENDPOINT IS HEALTHY — port-forward straight to the jackett pod's 44180:
# GET /metrics -> http=200 bytes=10208
@@ -54,19 +72,24 @@
# `jackett-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180 — the Gateway's Envoy
# and nothing else. Prometheus matches no clause and its packets are dropped, not refused.
#
# SO THE TARGETS BELOW WILL READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS. That is a known,
# measured, documented state — NOT a regression introduced here, and NOT something to silence.
# It is deliberately left visible: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
# THE TARGETS READ `up == 0` FROM 2026-10-02 05:09 UTC UNTIL THE ALLOWANCE LANDED — one
# continuous 63.7h episode (a 15d query_range shows 1 episode per namespace, not flapping),
# starting 11 minutes after this file was created, i.e. TargetDown's `for: 10m`. It was never
# silenced, and that was the right call: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
# requires `up == 1`, precisely so a discovered-but-failing target does not read as "observed"
# (see that file's header — this exact trap is why the `== 1` is there).
#
# The signature to recognise if it ever returns: lastScrapeDuration pinned at EXACTLY the
# scrapeTimeout (10.0s) with `context deadline exceeded`. That is a DROP, not a refusal — a
# refused connection fails in milliseconds. Post-fix the same targets scrape in 1-6ms.
#
# THIS IS THE SAME BUG LONGHORN HAD, and the platform has a documented fix idiom for it:
# platform/longhorn/networkpolicy-metrics.yaml, whose header records the identical fingerprint
# measured on 2026-09-13 — ServiceMonitor in place, 3 targets DISCOVERED, `up` 0/0/0, TSDB empty,
# `lastError: context deadline exceeded` (a drop, not a refusal). The fix there is a narrow
# additive `networking.k8s.io/v1` NetworkPolicy: ns monitoring -> the pod label, that one port.
#
# WHY THAT POLICY IS NOT IN THIS DIRECTORY. It would have to be five objects, one per app
# WHY NOT IN THIS DIRECTORY (the reasoning the fix followed). It would have to be five objects, one per app
# namespace, and those namespaces are created by apps-wave-0/1. Measured Flux graph, 2026-10-01:
# oauth2-proxy dependsOn=['platform-config'] <- this tier
# apps-wave-0 dependsOn=['platform-config'] <- SIBLING, no ordering between them
@@ -76,8 +99,19 @@
# running cluster and break on a rebuild — the "works now, broken on restore" shape this repo
# keeps paying for. Longhorn's precedent is safe only because longhorn-system is created by the
# same tier that ships its policy. The allowance therefore belongs WITH THE APPS, rendered next to
# the rest of each app's network policy — i.e. in the catalog/renderer, which is in flight on
# another branch. Tracked in the handoff; it is one narrow rule per app, not a redesign.
# the rest of each app's network policy — i.e. in the catalog/renderer. THAT IS WHERE IT LANDED:
# `r_monitoring_ingress_policy`, keyed on `_needs_oauth2_sidecar`, one narrow rule per app.
#
# --------------------------------------------------------------------------------------------
# CRAFTY: STILL UNOBSERVED, AND THIS FILE CANNOT HELP IT
# --------------------------------------------------------------------------------------------
# `OAuth2ProxyGateUnobserved{namespace="crafty"}` still fires after the fix, correctly. crafty is
# a hand-maintained Deployment under platform/crafty, OUTSIDE the renderer, and its oauth2-proxy
# container has NO `--metrics-address` at all (measured 2026-10-05) — so its metrics listener
# never starts and there is no port to open. The selector below therefore does not match it, and
# the per-app allowance is never rendered for it. Closing crafty's gap needs the flag plus a
# `metrics` containerPort added to its own manifest FIRST; the allowance is the second half, and
# it would have to be written by hand there rather than rendered. Open follow-up work.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata: