Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989649e64a | ||
|
|
422834d4e2 | ||
|
|
1f4552d409 | ||
|
|
c95b1c7121 | ||
|
|
ed6afc3d3f | ||
|
|
254985baa6 | ||
|
|
dce20b7be6 | ||
|
|
028853c87c | ||
|
|
17f0c11403 | ||
|
|
9a69f6906a |
No files matched your search
+41
-28
@@ -35,34 +35,18 @@ spec:
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
---
|
||||
# PVC pihole-config (R-STATE)
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: pihole-config
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
# Deployment (R-PLAT-02)
|
||||
# StatefulSet (R-PLAT-02)
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: pihole
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
replicas: 2
|
||||
serviceName: pihole
|
||||
podManagementPolicy: Parallel
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
@@ -97,7 +81,7 @@ spec:
|
||||
key: webui-password
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /data/config
|
||||
mountPath: /etc/pihole
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
@@ -175,10 +159,39 @@ spec:
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: pihole-config
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
topologyKey: kubernetes.io/hostname
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: config
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
# PodDisruptionBudget — keep one replica through a drain (availability.replicas)
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: pihole
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
---
|
||||
# Service
|
||||
apiVersion: v1
|
||||
@@ -281,11 +294,11 @@ spec:
|
||||
- name: pihole.slis
|
||||
rules:
|
||||
- alert: pihole-down
|
||||
expr: kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"} == 0 or absent(kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"})
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"} == 0 or absent(kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
service: pihole
|
||||
annotations:
|
||||
summary: pihole is down
|
||||
description: 'pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'
|
||||
description: 'pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready).'
|
||||
@@ -137,6 +137,8 @@ spec:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
- name: ADVERTISE_IP
|
||||
value: ${PLEX_ADVERTISE_URL}
|
||||
resources:
|
||||
limits:
|
||||
gpu.intel.com/i915: 1
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
|
||||
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
|
||||
# qbittorrent (container/Deployment) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
||||
# qbittorrent (container/Deployment), pihole (container/StatefulSet — `availability.replicas: 2`, so
|
||||
# its SLI reads kube_statefulset_status_replicas_ready; see schemas/renderer-contract.md
|
||||
# `### availability`) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
||||
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
|
||||
# tests run against the exact expressions Flux applies, never a copy.
|
||||
#
|
||||
@@ -41,7 +43,7 @@ tests:
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '1+0x20'
|
||||
@@ -81,7 +83,7 @@ tests:
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '0+0x20'
|
||||
@@ -132,7 +134,7 @@ tests:
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '1+0x20'
|
||||
@@ -170,10 +172,10 @@ tests:
|
||||
severity: critical
|
||||
service: pihole
|
||||
namespace: pihole
|
||||
deployment: pihole
|
||||
statefulset: pihole
|
||||
exp_annotations:
|
||||
summary: "pihole is down"
|
||||
description: "pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
||||
description: "pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready)."
|
||||
- eval_time: 10m
|
||||
alertname: qbittorrent-down
|
||||
exp_alerts:
|
||||
|
||||
@@ -12,6 +12,42 @@ storage:
|
||||
- name: config
|
||||
size: 2Gi
|
||||
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
||||
# THE PVC WAS MOUNTED SOMEWHERE PIHOLE NEVER WRITES, AND SO HELD NOTHING AT ALL. Without an
|
||||
# explicit mountPath the renderer defaults to /data/<name>, i.e. /data/config — but PiHole keeps
|
||||
# every piece of its state in /etc/pihole. MEASURED on live prod 2026-10-04: `/etc/pihole` (7.1 M
|
||||
# — gravity.db, pihole.toml, the query DB) was on the **overlay** filesystem, i.e. the
|
||||
# container's EPHEMERAL layer, while `/dev/longhorn/pvc-849edb86...` sat mounted at
|
||||
# /data/config holding `lost+found` and 24 K of nothing. Three consequences, all live until now:
|
||||
# * every restart silently discarded PiHole's whole state, re-downloading 72 k gravity domains
|
||||
# and regenerating the admin password hash;
|
||||
# * `backup.policy: daily-offcluster` below has been faithfully backing up an EMPTY volume,
|
||||
# which is worse than no backup because it reports success;
|
||||
# * it is also why phase 1's password/adlist declaration mattered more than it first appeared —
|
||||
# that state was not merely undeclared, it was ephemeral.
|
||||
# This is the same class of bug catalog/plex.yaml's `config` entry documents ("Without this the
|
||||
# PVC sat unused and Plex wrote to the container's EPHEMERAL layer").
|
||||
#
|
||||
# /etc/pihole IS THE IMAGE'S INTENDED MOUNT POINT, and an EMPTY volume there self-initialises.
|
||||
# Verified in the running image, not assumed:
|
||||
# * `/usr/bin/start.sh` (PID 1) calls `migrate_gravity`, whose `migrate_to_database()` creates
|
||||
# the gravity database when `[ ! -e "$gravityDBfile" ]` — the same branch the postStart hook
|
||||
# below relies on, and the branch that produced the current ephemeral copy.
|
||||
# * FTL writes `pihole.toml` itself from the `FTLCONF_*` env above (that is what the
|
||||
# `### CHANGED (env)` markers in it are).
|
||||
# * NO `podSecurityContext.fsGroup` is needed and adding one would be inert: `start.sh` line 5
|
||||
# is `chown -R pihole:pihole /etc/pihole/ /var/log/pihole/` and PID 1 runs as **uid 0**, so
|
||||
# the image fixes ownership of a fresh root-owned volume before anything else starts. Same
|
||||
# arrangement as plex's image managing its own /config ownership.
|
||||
# * the image's own default for `files.macvendor` was moved OUT of this directory (to
|
||||
# `/macvendor.db`), which is what a maintainer does to a path they expect to be a volume.
|
||||
#
|
||||
# WHY IT IS FIXED IN THIS BRANCH rather than left for its own: `availability.replicas: 2` renders
|
||||
# per-replica volumeClaimTemplates, and with the wrong mountPath those would be two 2 Gi Longhorn
|
||||
# volumes (times three Longhorn replicas each) holding nothing but `lost+found` while all state
|
||||
# stayed ephemeral — paying the cost of persistence and getting none of it. It also REMOVES this
|
||||
# change's main operational risk: with gravity.db persisted, a pod restart is cheap, instead of
|
||||
# every restart re-downloading 72 k domains inside a wave that has a 5 m timeout.
|
||||
mountPath: /etc/pihole
|
||||
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to.
|
||||
@@ -114,6 +150,39 @@ env:
|
||||
secretKeyRef:
|
||||
name: pihole-bootstrap
|
||||
key: webui-password
|
||||
availability:
|
||||
# TWO REPLICAS, ON DIFFERENT NODES. LAN DNS is a critical network service and it was ONE pod on
|
||||
# ONE node. Measured 2026-10-04, that cost more than "until it reschedules":
|
||||
# * the node running the pod dies -> ~40s to mark it NotReady, then `tolerationSeconds: 300`
|
||||
# before the pod is even evicted, then reschedule, then Longhorn must force-detach the RWO
|
||||
# volume from a dead node and reattach it. Roughly 6-10 MINUTES with no LAN resolution.
|
||||
# * the node holding the `pihole-dns-*` L2 lease dies -> Cilium re-elects an announcer, tens of
|
||||
# seconds. (These were DIFFERENT nodes: lease on k3s-prod-2, pod on k3s-prod-1. Two of three
|
||||
# nodes were single points of failure for LAN DNS, with wildly different blast radii.)
|
||||
# The Services already tolerate this: `externalTrafficPolicy: Cluster` on the DNS LoadBalancers
|
||||
# (apps/wave-0/pihole-dns.yaml) means the L2 lease holder forwards to ANY ready backend, so a
|
||||
# second replica is picked up with no change there.
|
||||
#
|
||||
# WHAT THIS DOES NOT FIX, and it is the bigger risk: LAN DNS still depends on the CLUSTER. etcd
|
||||
# quorum loss, a Cilium regression, a Longhorn problem or a bad reconcile takes DNS down
|
||||
# everywhere regardless of replica count. The only thing that removes that dependency is a
|
||||
# resolver outside the cluster carrying the same `address=` lines. Considered and not chosen
|
||||
# (operator decision, 2026-10-04); see decisions/2026-10-04-pihole-multi-replica.md.
|
||||
#
|
||||
# THE PREREQUISITE WAS DONE FIRST, and this field would have been actively harmful without it. A
|
||||
# replica gets a FRESH volume, and until 2026-10-04 PiHole's admin password and adlists lived only
|
||||
# on the PVC — so replica 1 would have come up SERVING DNS AND BLOCKING NOTHING, with a different
|
||||
# password, and answers would have varied by which pod replied. Both are now declared (the
|
||||
# password as a SOPS Secret -> FTLCONF_webserver_api_password; the adlists reconciled into
|
||||
# gravity.db by the postStart hook below), which is what makes a fresh replica identical to its
|
||||
# sibling. See decisions/2026-10-04-pihole-declare-state.md.
|
||||
#
|
||||
# WHY 2 AND NOT 3. Two tolerates the loss of any single node, which is the stated goal, and leaves
|
||||
# a spare node for the evicted replica to land on under the HARD anti-affinity the renderer emits.
|
||||
# Three would pin one replica per node with no spare, so a single node failure would leave a
|
||||
# replica permanently Pending and — `apps-wave-0` being `wait: true` — stall the app tier for as
|
||||
# long as the node was down. Two is the count that degrades gracefully here.
|
||||
replicas: 2
|
||||
ingressRoutes:
|
||||
- host: pihole.${BASE_DOMAIN}
|
||||
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
||||
|
||||
+48
-8
@@ -72,14 +72,54 @@ env:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
||||
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
|
||||
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
|
||||
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
|
||||
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
|
||||
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
|
||||
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
|
||||
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
|
||||
# ADVERTISE_IP ("Custom server access URLs") — NOW SET. This replaces a DEFERRAL note that stood
|
||||
# here until 2026-10-04, and the deferral's premise has changed: it weighed only ONE candidate,
|
||||
# `https://plex.home.keithjackson.dev` through the L7 Gateway. The operator chose the NATIVE L4
|
||||
# path instead on 2026-10-03 (apps/wave-0/plex-remote.yaml) so streams go straight to
|
||||
# ${PLEX_L4_VIP}:32400 and bypass the Envoy hop. What was still missing was a URL whose TLS
|
||||
# CERTIFICATE validates — and getting that wrong is what took the service down.
|
||||
#
|
||||
# THE VALUE IS A `plex.direct` NAME, NOT THE RAW VIP, AND THAT IS THE WHOLE POINT. Plex serves
|
||||
# 32400 with its own certificate; measured live 2026-10-04 it carries exactly one name:
|
||||
# CN = *.<serverhash>.plex.direct (one SAN, no IP SANs at all)
|
||||
# so `https://<ip>:32400` can NEVER validate. `<dashed-ip>.<serverhash>.plex.direct` is Plex's own
|
||||
# mechanism for this: it is PUBLIC DNS that resolves to the embedded address, so the name points at
|
||||
# the LAN VIP while the wildcard cert above matches it. Measured 2026-10-04 with FULL verification
|
||||
# (no `curl -k`):
|
||||
# https://192-168-2-24.<hash>.plex.direct:32400/identity -> 200, TLS verify OK
|
||||
# https://192.168.2.24:32400/identity -> 000, cert validation FAILED
|
||||
#
|
||||
# THIS FIELD EXISTS BECAUSE AUTO-DISCOVERY CANNOT WORK HERE. Left empty, Plex publishes the
|
||||
# addresses it can see itself — which from a pod is the overlay IP (`10.0.2.136`), useless to a LAN
|
||||
# client. The VIP has to be advertised explicitly; there is no "just clear it" fix.
|
||||
#
|
||||
# WHAT WENT WRONG, recorded so it is not re-introduced by hand. customConnections OVERRIDES Plex's
|
||||
# own publishing, and on 2026-10-04 it held two hand-typed URLs, both uncompletable:
|
||||
# - `https://192.168.2.24:32400` -> cert cannot cover an IP. Measured: a storm of
|
||||
# `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` in Plex's log every few
|
||||
# seconds, from REAL LAN clients arriving through the VIP.
|
||||
# - `https://plex.home.keithjackson.dev:32400` -> nothing listens. The Gateway serves this host on
|
||||
# 443; measured `http=000` against 192.168.2.20:32400.
|
||||
# With every advertised connection dead, plex.tv had nothing publishable, sat at
|
||||
# `mapping state 'Mapped - Not Published'`, fell back to Plex Relay, and THE SERVER DISAPPEARED
|
||||
# FROM THE ACCOUNT — clients reported "not authorized".
|
||||
#
|
||||
# DECLARING IT HERE rather than in the web UI is the durable half of the fix. The image's
|
||||
# /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections" "${ADVERTISE_IP}"` (line 127)
|
||||
# OUTSIDE the PLEX_CLAIM/PlexOnlineToken guard, and its `/.firstRunComplete` marker sits on the
|
||||
# container's OVERLAY fs, not the /config PVC — so this is re-applied on EVERY pod start and git,
|
||||
# not a form field, owns the value. Editing this env rolls the Deployment, which is what applies it.
|
||||
#
|
||||
# TWO LIMITS, stated rather than discovered later:
|
||||
# - THE HASH IS SERVER IDENTITY, which is why the whole URL is a cluster-var and not built from
|
||||
# ${PLEX_L4_VIP}: envsubst cannot dash an IP, and the hash is not derivable from anything in
|
||||
# this tree. It is not a secret (it ships in the cert and in the published connection list), but
|
||||
# if Plex reissues under a new hash this URL goes stale and must be re-read from the live cert.
|
||||
# - THIS RESTORES LAN CLIENTS AND THE ACCOUNT LISTING, NOT WAN. Remote clients still need the
|
||||
# off-repo tcp/32400 port-forward (runbooks/plex-remote-access.md). Once that exists, the WAN
|
||||
# twin of this URL can be appended comma-separated — customConnections takes a list.
|
||||
- name: ADVERTISE_IP
|
||||
value: ${PLEX_ADVERTISE_URL}
|
||||
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
|
||||
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
|
||||
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||
|
||||
+283
-10
@@ -272,7 +272,15 @@ def apply_defaults(sd: dict) -> dict:
|
||||
# object that actually runs on the cluster is not the one this SD renders (keycloak: the
|
||||
# Keycloak operator runs a StatefulSet, so a Deployment-shaped rule would `absent()` forever).
|
||||
lw = dict(obs.get("livenessWorkload") or {})
|
||||
lw.setdefault("kind", "VirtualMachineInstance" if s["kind"] == "vm" else "Deployment")
|
||||
# The default must track what is ACTUALLY rendered, or the `up` SLI silently dies: a
|
||||
# Deployment-shaped rule against a StatefulSet `absent()`s forever, which is an alert that can
|
||||
# never fire — the exact failure mode the keycloak override was added for. Deriving it from
|
||||
# `availability.replicas` means bumping the replica count cannot leave a stale rule behind.
|
||||
lw.setdefault(
|
||||
"kind",
|
||||
"VirtualMachineInstance" if s["kind"] == "vm"
|
||||
else ("StatefulSet" if _is_statefulset(s) else "Deployment"),
|
||||
)
|
||||
lw.setdefault("name", s["name"])
|
||||
obs["livenessWorkload"] = lw
|
||||
s["observability"] = obs
|
||||
@@ -475,6 +483,37 @@ def r_bulk_pvs(s: dict) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _replicas(s: dict) -> int:
|
||||
"""Declared replica count (schema 1.10.0 `availability.replicas`). Default 1."""
|
||||
return int((s.get("availability") or {}).get("replicas", 1) or 1)
|
||||
|
||||
|
||||
def _is_statefulset(s: dict) -> bool:
|
||||
"""True when this container workload must render a StatefulSet rather than a Deployment.
|
||||
|
||||
THE DECIDING CONSTRAINT IS THE VOLUME, not a preference. A longhorn storage entry renders
|
||||
ReadWriteOnce (r_pvcs), and one RWO PVC cannot be attached by pods on two different nodes --
|
||||
which is exactly what `availability.replicas > 1` plus the node anti-affinity below asks for.
|
||||
A 2-replica Deployment over a shared RWO claim does not degrade, it simply never schedules the
|
||||
second pod. A StatefulSet's volumeClaimTemplates give each replica its OWN claim, which is the
|
||||
only shape that satisfies both at once.
|
||||
|
||||
A replicas-1 service keeps rendering a Deployment so every pre-existing golden is byte-identical.
|
||||
"""
|
||||
return s["kind"] == "container" and _replicas(s) > 1
|
||||
|
||||
|
||||
def _sts_claim_volumes(s: dict) -> list:
|
||||
"""Storage entries that become per-replica volumeClaimTemplates on a StatefulSet.
|
||||
|
||||
Only the dynamically-provisioned (longhorn) entries. A `bulk` entry is a STATIC, pre-bound NFS
|
||||
PV over data that already exists outside the cluster and is shared read-only by every consumer
|
||||
(r_bulk_pvs) -- per-replica copies of it are neither possible nor wanted, so it stays a
|
||||
standalone PVC and an ordinary pod volume.
|
||||
"""
|
||||
return [v for v in s["storage"] if v.get("class", "longhorn") != "bulk"]
|
||||
|
||||
|
||||
def r_pvcs(s: dict) -> list:
|
||||
"""One PVC per storage entry (R-STATE-01/02/04).
|
||||
|
||||
@@ -482,8 +521,15 @@ def r_pvcs(s: dict) -> list:
|
||||
bulk -> the STATIC external NFS PV rendered by r_bulk_pvs, pre-bound by volumeName.
|
||||
"""
|
||||
out = []
|
||||
# On a StatefulSet the longhorn claims are NOT standalone objects — they are
|
||||
# volumeClaimTemplates, so each replica gets its own (see _is_statefulset). Emitting both would
|
||||
# leave an orphan PVC that nothing mounts and that `prune` would later delete out from under
|
||||
# nobody. `bulk` is unaffected and still rendered here.
|
||||
sts = _is_statefulset(s)
|
||||
for vol in s["storage"]:
|
||||
cls = vol.get("class", "longhorn")
|
||||
if sts and cls != "bulk":
|
||||
continue
|
||||
if cls == "bulk":
|
||||
# Mirror image of the PV: the same explicitly-empty class (so nothing dynamically
|
||||
# provisions), the same access mode, and volumeName completing the two-sided binding.
|
||||
@@ -564,6 +610,12 @@ def r_deployment(s: dict) -> dict:
|
||||
if vol.get("readOnly"):
|
||||
mount["readOnly"] = True
|
||||
volume_mounts.append(mount)
|
||||
# On a StatefulSet a longhorn volume is supplied by its volumeClaimTemplate, whose
|
||||
# metadata.name IS the pod volume name. Adding it to pod.volumes as well is a conflict
|
||||
# (duplicate volume name), so it is skipped here and emitted below. The MOUNT is unchanged —
|
||||
# the container cannot tell the difference, which is what keeps this transparent to the app.
|
||||
if _is_statefulset(s) and vol.get("class", "longhorn") != "bulk":
|
||||
continue
|
||||
volumes.append(
|
||||
{
|
||||
"name": vol["name"],
|
||||
@@ -661,20 +713,80 @@ def r_deployment(s: dict) -> dict:
|
||||
if ann:
|
||||
tmpl_meta["annotations"] = ann
|
||||
|
||||
spec = {"replicas": 1}
|
||||
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
||||
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
||||
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
||||
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
||||
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
||||
if s["storage"]:
|
||||
sts = _is_statefulset(s)
|
||||
|
||||
# NODE ANTI-AFFINITY, and it is REQUIRED rather than preferred. `availability.replicas > 1`
|
||||
# exists to survive the loss of one node; `preferred` would let both replicas land on the same
|
||||
# node and quietly deliver none of that, with every object reporting healthy. The cost is real
|
||||
# and is accepted: with N replicas the workload needs N schedulable nodes, so on a 3-node
|
||||
# cluster losing TWO nodes leaves a replica Pending and — because `apps-wave-0` is `wait: true`
|
||||
# with `apps-wave-1` dependsOn it — STALLS THE APP TIER. That coincides with having already lost
|
||||
# etcd quorum on a 3-node control plane, i.e. the cluster is not reconciling anything at that
|
||||
# point anyway, so the stall is a symptom rather than a new failure.
|
||||
if sts:
|
||||
pod_spec["affinity"] = {
|
||||
"podAntiAffinity": {
|
||||
"requiredDuringSchedulingIgnoredDuringExecution": [
|
||||
{
|
||||
"labelSelector": {
|
||||
"matchLabels": {"app.kubernetes.io/name": s["name"]}
|
||||
},
|
||||
"topologyKey": "kubernetes.io/hostname",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
spec = {"replicas": _replicas(s)}
|
||||
if sts:
|
||||
# serviceName is a required StatefulSet field. It points at the service rendered by
|
||||
# r_service; that Service is a normal ClusterIP rather than headless, so per-pod DNS
|
||||
# (<pod>.<svc>) does NOT resolve — and nothing here needs it. These replicas are
|
||||
# interchangeable resolvers behind one VIP, not peers that address each other.
|
||||
spec["serviceName"] = s["name"]
|
||||
# Parallel, not the OrderedReady default. OrderedReady starts replica N+1 only after N is
|
||||
# Ready, so a cold start costs the sum of every replica's first-boot work instead of the
|
||||
# max — and `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between
|
||||
# replicas to protect (no leader, no migration, no shared volume), so serialising buys
|
||||
# nothing and risks the wave timing out on a rebuild.
|
||||
spec["podManagementPolicy"] = "Parallel"
|
||||
elif s["storage"]:
|
||||
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
||||
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
||||
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
||||
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
||||
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
||||
#
|
||||
# A StatefulSet needs none of this and cannot express it: each replica owns its own claim,
|
||||
# so a rolling update hands nothing over, and `strategy` is not a StatefulSet field at all.
|
||||
spec["strategy"] = {"type": "Recreate"}
|
||||
spec["selector"] = {"matchLabels": {"app.kubernetes.io/name": s["name"]}}
|
||||
spec["template"] = {"metadata": tmpl_meta, "spec": pod_spec}
|
||||
if sts:
|
||||
# Per-replica claims. metadata.name MUST equal the pod volume name the container mounts
|
||||
# (see the volumes loop above); the resulting PVCs are <name>-<sts>-<ordinal>, e.g.
|
||||
# config-pihole-0. Same tier and access mode a standalone longhorn PVC gets from r_pvcs —
|
||||
# this changes WHO owns the claim, never what it is.
|
||||
tmpls = []
|
||||
for vol in _sts_claim_volumes(s):
|
||||
vspec = {"accessModes": ["ReadWriteOnce"], "storageClassName": "longhorn"}
|
||||
if vol.get("size"):
|
||||
vspec["resources"] = {"requests": {"storage": vol["size"]}}
|
||||
tmpls.append(
|
||||
{
|
||||
"metadata": {
|
||||
"name": vol["name"],
|
||||
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||
},
|
||||
"spec": vspec,
|
||||
}
|
||||
)
|
||||
if tmpls:
|
||||
spec["volumeClaimTemplates"] = tmpls
|
||||
|
||||
return {
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"kind": "StatefulSet" if sts else "Deployment",
|
||||
"metadata": {
|
||||
"name": s["name"],
|
||||
"namespace": s["name"],
|
||||
@@ -684,6 +796,35 @@ def r_deployment(s: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def r_pdb(s: dict):
|
||||
"""PodDisruptionBudget for a multi-replica workload (availability.replicas > 1).
|
||||
|
||||
minAvailable 1, not `maxUnavailable: 1`. The budget's job here is to stop a VOLUNTARY
|
||||
disruption — a node drain for maintenance, a cluster upgrade — from taking the last replica
|
||||
with it; `minAvailable: 1` says that whatever else happens, one must keep serving. It does
|
||||
NOT protect against involuntary disruption (a node dying), which is what the anti-affinity
|
||||
and the replica count are for.
|
||||
|
||||
Rendered only when replicas > 1: a PDB over a single-replica workload is actively harmful, as
|
||||
`minAvailable: 1` would then block every drain of its node forever.
|
||||
"""
|
||||
if not _is_statefulset(s):
|
||||
return None
|
||||
return {
|
||||
"apiVersion": "policy/v1",
|
||||
"kind": "PodDisruptionBudget",
|
||||
"metadata": {
|
||||
"name": s["name"],
|
||||
"namespace": s["name"],
|
||||
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||
},
|
||||
"spec": {
|
||||
"minAvailable": 1,
|
||||
"selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def r_service(s: dict):
|
||||
ports = _service_ports(s)
|
||||
# A declared metrics endpoint becomes a NAMED Service port, because that name is the only
|
||||
@@ -1817,7 +1958,13 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None)
|
||||
)
|
||||
for pvc in r_pvcs(s):
|
||||
docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc))
|
||||
docs.append(("Deployment (R-PLAT-02)", r_deployment(s)))
|
||||
wl = r_deployment(s)
|
||||
docs.append((f"{wl['kind']} (R-PLAT-02)", wl))
|
||||
pdb = r_pdb(s)
|
||||
if pdb:
|
||||
docs.append(
|
||||
("PodDisruptionBudget — keep one replica through a drain (availability.replicas)", pdb)
|
||||
)
|
||||
svc = r_service(s)
|
||||
if svc:
|
||||
docs.append(("Service", svc))
|
||||
@@ -2266,6 +2413,132 @@ def _selftest() -> int:
|
||||
check("Namespace" in kinds, "container: missing Namespace")
|
||||
check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy")
|
||||
check("Deployment" in kinds, "container: missing Deployment")
|
||||
check(
|
||||
"PodDisruptionBudget" not in kinds,
|
||||
"replicas-1 container must render NO PodDisruptionBudget (minAvailable 1 over a single "
|
||||
"replica blocks every drain of its node forever)",
|
||||
)
|
||||
|
||||
# 1b) availability.replicas (schema 1.10.0). The DEFAULT path must be untouched, and the >1
|
||||
# path must change workload kind, claim ownership, spreading, budget AND the SLI metric
|
||||
# together — each of these has a silent-failure mode if it is the one that gets forgotten.
|
||||
base_av = {
|
||||
"name": "av",
|
||||
"source": {"image": "nginx:1"},
|
||||
"storage": [{"name": "config", "size": "1Gi"}],
|
||||
"observability": {"metrics": True, "slis": ["up"]},
|
||||
}
|
||||
one = render({**base_av, "availability": {"replicas": 1}})
|
||||
one_kinds = [d[1]["kind"] for d in one]
|
||||
check(
|
||||
"Deployment" in one_kinds and "StatefulSet" not in one_kinds,
|
||||
"availability.replicas=1 must still render a Deployment (back-compat)",
|
||||
)
|
||||
check(
|
||||
"PersistentVolumeClaim" in one_kinds,
|
||||
"availability.replicas=1 must still render a standalone PVC",
|
||||
)
|
||||
check(
|
||||
"PodDisruptionBudget" not in one_kinds,
|
||||
"availability.replicas=1 must render no PodDisruptionBudget",
|
||||
)
|
||||
|
||||
many = render({**base_av, "availability": {"replicas": 2}})
|
||||
many_kinds = [d[1]["kind"] for d in many]
|
||||
check(
|
||||
"StatefulSet" in many_kinds and "Deployment" not in many_kinds,
|
||||
"availability.replicas>1 must render a StatefulSet, not a Deployment (a shared RWO claim "
|
||||
"cannot be attached on two nodes, so a multi-replica Deployment never schedules)",
|
||||
)
|
||||
check(
|
||||
"PersistentVolumeClaim" not in many_kinds,
|
||||
"availability.replicas>1 must NOT also render a standalone longhorn PVC — it would be an "
|
||||
"orphan that nothing mounts",
|
||||
)
|
||||
sts = next(d[1] for d in many if d[1]["kind"] == "StatefulSet")
|
||||
check(sts["spec"].get("replicas") == 2, "StatefulSet must carry the declared replica count")
|
||||
check(
|
||||
"strategy" not in sts["spec"],
|
||||
"StatefulSet must not carry `strategy` (not a StatefulSet field; Recreate is a "
|
||||
"Deployment-only workaround for handing over one RWO volume)",
|
||||
)
|
||||
check(
|
||||
sts["spec"].get("podManagementPolicy") == "Parallel",
|
||||
"StatefulSet must set podManagementPolicy=Parallel — OrderedReady serialises first-boot "
|
||||
"work across replicas and apps-wave-0 has a 5m timeout",
|
||||
)
|
||||
check(sts["spec"].get("serviceName") == "av", "StatefulSet must set serviceName")
|
||||
tmpls = sts["spec"].get("volumeClaimTemplates") or []
|
||||
check(
|
||||
[t["metadata"]["name"] for t in tmpls] == ["config"],
|
||||
"StatefulSet volumeClaimTemplate name must equal the pod volume name the container mounts, "
|
||||
"or the pod gets a duplicate/unresolvable volume",
|
||||
)
|
||||
check(
|
||||
tmpls and tmpls[0]["spec"]["accessModes"] == ["ReadWriteOnce"]
|
||||
and tmpls[0]["spec"]["storageClassName"] == "longhorn",
|
||||
"StatefulSet claim template must keep the same tier/access mode as the standalone PVC",
|
||||
)
|
||||
sts_vols = {v["name"] for v in sts["spec"]["template"]["spec"].get("volumes", [])}
|
||||
check(
|
||||
"config" not in sts_vols,
|
||||
"a claim-template volume must NOT also appear in pod.volumes (duplicate volume name)",
|
||||
)
|
||||
mounts = sts["spec"]["template"]["spec"]["containers"][0].get("volumeMounts") or []
|
||||
check(
|
||||
any(m["name"] == "config" for m in mounts),
|
||||
"the container must still mount the volume by name on a StatefulSet (transparent to the app)",
|
||||
)
|
||||
aff = (sts["spec"]["template"]["spec"].get("affinity") or {}).get("podAntiAffinity") or {}
|
||||
req = aff.get("requiredDuringSchedulingIgnoredDuringExecution") or []
|
||||
check(
|
||||
req and req[0].get("topologyKey") == "kubernetes.io/hostname",
|
||||
"availability.replicas>1 must render REQUIRED per-node anti-affinity — `preferred` can put "
|
||||
"every replica on one node and deliver none of the intended node-failure tolerance",
|
||||
)
|
||||
pdbs = [d[1] for d in many if d[1]["kind"] == "PodDisruptionBudget"]
|
||||
check(len(pdbs) == 1, "availability.replicas>1 must render exactly one PodDisruptionBudget")
|
||||
check(
|
||||
pdbs and pdbs[0]["spec"].get("minAvailable") == 1,
|
||||
"PodDisruptionBudget must be minAvailable=1 (keep one replica serving through a drain)",
|
||||
)
|
||||
pr_many = next((d[1] for d in many if d[1]["kind"] == "PrometheusRule"), None)
|
||||
check(
|
||||
pr_many is not None
|
||||
and "kube_statefulset_status_replicas_ready"
|
||||
in pr_many["spec"]["groups"][0]["rules"][0]["expr"],
|
||||
"the up SLI must follow the rendered workload kind — a Deployment-shaped rule against a "
|
||||
"StatefulSet absent()s forever and can never fire",
|
||||
)
|
||||
|
||||
# A bulk volume is shared external NFS: it must stay a standalone PVC even on a StatefulSet.
|
||||
mixed = render(
|
||||
{
|
||||
**base_av,
|
||||
"availability": {"replicas": 2},
|
||||
"storage": [
|
||||
{"name": "config", "size": "1Gi"},
|
||||
{"name": "media", "class": "bulk", "export": "/mnt/x", "size": "1Ti"},
|
||||
],
|
||||
}
|
||||
)
|
||||
m_sts = next(d[1] for d in mixed if d[1]["kind"] == "StatefulSet")
|
||||
check(
|
||||
[t["metadata"]["name"] for t in m_sts["spec"]["volumeClaimTemplates"]] == ["config"],
|
||||
"only longhorn volumes become claim templates; a bulk share must not be per-replica",
|
||||
)
|
||||
check(
|
||||
any(
|
||||
d[1]["kind"] == "PersistentVolumeClaim"
|
||||
and d[1]["metadata"]["name"] == "av-media"
|
||||
for d in mixed
|
||||
),
|
||||
"a bulk PVC must still be rendered standalone on a StatefulSet",
|
||||
)
|
||||
check(
|
||||
"media" in {v["name"] for v in m_sts["spec"]["template"]["spec"].get("volumes", [])},
|
||||
"a bulk volume must still appear in pod.volumes on a StatefulSet",
|
||||
)
|
||||
|
||||
ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace")
|
||||
check(
|
||||
|
||||
@@ -101,7 +101,7 @@ data:
|
||||
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
||||
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
|
||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
|
||||
# plex-remote: PLEX_L4_VIP),
|
||||
# plex-remote: PLEX_L4_VIP; plex: PLEX_ADVERTISE_URL),
|
||||
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
||||
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
||||
@@ -190,6 +190,32 @@ data:
|
||||
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
|
||||
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
|
||||
|
||||
# PLEX_ADVERTISE_URL — the connection URL Plex publishes to plex.tv ("Custom server access URLs",
|
||||
# the ADVERTISE_IP env in catalog/plex.yaml, which the image writes into the `customConnections`
|
||||
# preference on every pod start). Read catalog/plex.yaml's ADVERTISE_IP block for the full why;
|
||||
# the short version is the only thing that matters when editing this line:
|
||||
#
|
||||
# IT MUST BE A `<dashed-ip>.<serverhash>.plex.direct` NAME, NEVER A BARE IP. Plex serves 32400
|
||||
# with a `*.<serverhash>.plex.direct` wildcard certificate and NO IP SAN, so `https://<ip>:32400`
|
||||
# fails TLS validation for every client. That exact mistake is what broke this service on
|
||||
# 2026-10-04: with no completable connection, plex.tv published nothing, the server vanished from
|
||||
# the account and clients reported "not authorized". The dashed name is public DNS that resolves
|
||||
# to the embedded address, so it points at the LAN VIP AND matches the cert.
|
||||
#
|
||||
# WHY IT IS A WHOLE URL AND NOT BUILT FROM ${PLEX_L4_VIP}: postBuild substitution is plain
|
||||
# envsubst — it cannot turn 192.168.2.24 into 192-168-2-24 — and `<serverhash>` is this server's
|
||||
# certificate identity, derivable from nothing in this tree. Re-read it from the live cert
|
||||
# (`openssl s_client -connect <vip>:32400 | openssl x509 -noout -subject`) if Plex ever reissues.
|
||||
#
|
||||
# The dashed IP here MUST track ${PLEX_L4_VIP} above. They are two halves of one address and
|
||||
# nothing checks that they agree.
|
||||
#
|
||||
# dev -> PLACEHOLDER, non-functional. dev is decommissioned and has no claimed Plex server, so
|
||||
# there is no real hash to put here; the base default only has to be a defined, correctly
|
||||
# shaped string (StrictPostBuildSubstitutions makes an UNDEFINED var a hard failure).
|
||||
# prod -> the real 192.168.2.24 + live server hash; see clusters/prod/kustomization.yaml.
|
||||
PLEX_ADVERTISE_URL: "https://192-168-1-57.0000000000000000000000000000000f.plex.direct:32400"
|
||||
|
||||
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
|
||||
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
||||
# from `world` is an open resolver and an amplification reflector, so the allow is scoped to this
|
||||
|
||||
@@ -496,6 +496,16 @@ patches:
|
||||
- op: replace
|
||||
path: /data/PLEX_L4_VIP
|
||||
value: "192.168.2.24"
|
||||
# PLEX_ADVERTISE_URL — the prod VIP above in Plex's `plex.direct` form, which is the ONLY form
|
||||
# that validates: the server's cert is `*.130072f20c334d6c8f63d0dfef87309b.plex.direct` with no
|
||||
# IP SAN (read off the live listener 2026-10-04), so a bare `https://192.168.2.24:32400` fails
|
||||
# TLS for every client — the 2026-10-04 outage. Measured with full verification, no `curl -k`:
|
||||
# this URL answers /identity with 200 and TLS verify OK; the bare-IP form fails to connect.
|
||||
# THE DASHED IP MUST MATCH /data/PLEX_L4_VIP ABOVE (192.168.2.24 -> 192-168-2-24); nothing
|
||||
# enforces that. The hash is server identity, not a secret, and changes only if Plex reissues.
|
||||
- op: replace
|
||||
path: /data/PLEX_ADVERTISE_URL
|
||||
value: "https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400"
|
||||
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
|
||||
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
|
||||
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
date: 2026-10-03
|
||||
branch: fix/cilium-cgroup-hostroot
|
||||
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
|
||||
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
|
||||
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
|
||||
`autoMount` stays `false`.
|
||||
|
||||
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
|
||||
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
|
||||
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
|
||||
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
|
||||
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
|
||||
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
|
||||
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
|
||||
agent.
|
||||
|
||||
**The consequence.** Every other host-netns process got no Service/LB translation:
|
||||
- Service ClusterIPs were unreachable from every node's host.
|
||||
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
|
||||
Other nodes reached it only because their packets crossed the wire to the lease holder, where
|
||||
the tc datapath handles them.
|
||||
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
|
||||
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
|
||||
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
|
||||
OIDC clients failed on whichever calls landed on that apiserver.
|
||||
- Webhooks and aggregated APIs were unaffected only because k3s runs
|
||||
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
|
||||
|
||||
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
|
||||
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
|
||||
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
|
||||
host.
|
||||
|
||||
## What I verified
|
||||
|
||||
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
|
||||
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
|
||||
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
|
||||
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
|
||||
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
|
||||
`cgroup2`.
|
||||
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
|
||||
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
|
||||
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
|
||||
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
|
||||
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
|
||||
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
|
||||
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
|
||||
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
|
||||
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
|
||||
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
|
||||
change only makes Service/LB addresses reachable from the host where they currently fail.
|
||||
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
|
||||
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
|
||||
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
|
||||
(no `mount-cgroup`).
|
||||
- Toolbox gate: see the PR description.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
|
||||
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
|
||||
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
|
||||
`authenticator not initialized` with no k3s restart.
|
||||
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
|
||||
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
|
||||
- An end-to-end kubectl OIDC login (needs a browser).
|
||||
- No regression test was added. A natural one is an L2 conformance check that a host-netns
|
||||
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
|
||||
path. Left as a follow-up.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/hubble-flow-export-plex
|
||||
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
|
||||
address connected to Plex. Not bandwidth metering, which does not matter here.
|
||||
|
||||
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
|
||||
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
|
||||
before forwarding to the backend pod:
|
||||
|
||||
| Measured 2026-10-04 | Value |
|
||||
|---|---|
|
||||
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
|
||||
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
|
||||
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
|
||||
| `192.168.x` sources in Plex's log, ever | **0** |
|
||||
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
|
||||
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
|
||||
|
||||
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
|
||||
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
|
||||
because the backend pod is on a different node.
|
||||
|
||||
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
|
||||
translation, so Hubble already holds the true source:
|
||||
|
||||
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
|
||||
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
|
||||
|
||||
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
|
||||
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
|
||||
|
||||
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
|
||||
|
||||
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
|
||||
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
|
||||
`{"source_port":["32400"]}`.
|
||||
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
|
||||
be a permanent Helm diff and an avoidable agent rollout.
|
||||
|
||||
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
|
||||
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
|
||||
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
|
||||
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
|
||||
`stdout` can be defined as `hubble-export-file-path`."* Query:
|
||||
|
||||
{namespace="kube-system", app="cilium"} |= "32400"
|
||||
|
||||
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
|
||||
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
|
||||
address did not match it, because at the point of observation the destination identity is not yet
|
||||
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
|
||||
exists to capture.
|
||||
|
||||
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
|
||||
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
|
||||
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
|
||||
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
|
||||
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
|
||||
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
|
||||
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
|
||||
pinning both the pod and the announcement to one node and surrendering Plex's ability to
|
||||
reschedule — a real availability loss to buy a log field. Same trap
|
||||
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
|
||||
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
|
||||
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
|
||||
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
|
||||
logging.
|
||||
|
||||
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
|
||||
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
|
||||
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
|
||||
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
|
||||
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
|
||||
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
|
||||
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
|
||||
only because metering is explicitly not a goal here.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only.
|
||||
|
||||
- **Every measurement in the table above**, including the VIP-lease-to-node map
|
||||
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
|
||||
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
|
||||
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
|
||||
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
|
||||
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
|
||||
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
|
||||
contains no `hubble-export-*` key at all.
|
||||
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
|
||||
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
|
||||
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
|
||||
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
|
||||
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
|
||||
the docs for the running version — not inferred from the crafty incident.
|
||||
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
|
||||
which is what rules DSR out.
|
||||
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
|
||||
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
|
||||
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
|
||||
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
|
||||
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
|
||||
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
|
||||
stdout needs no Alloy change.
|
||||
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
|
||||
with `export.static` resolving to the intended object.
|
||||
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two known environmental skips
|
||||
(`gitea-settings` — no local `SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE`
|
||||
unset). **Not green. Reported as rc 3.**
|
||||
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
|
||||
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
|
||||
still has no `hubble-export-*` key. Every claim is about inputs.
|
||||
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
|
||||
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
|
||||
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
|
||||
and the chart default is a real filesystem path. If the literal string is not special-cased, the
|
||||
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
|
||||
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
|
||||
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
|
||||
`/var/run/cilium/hubble`.
|
||||
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
|
||||
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
|
||||
but upstream gives **no port example** and I did not validate it against the running agent. A
|
||||
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
|
||||
nothing.
|
||||
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
|
||||
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
|
||||
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
|
||||
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
|
||||
quiet moment.
|
||||
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
|
||||
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
|
||||
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
|
||||
by dropping a filter.
|
||||
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
|
||||
set actually matches (it depends on the cilium pods carrying
|
||||
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
|
||||
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
|
||||
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
|
||||
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
|
||||
is not fed a post-translation view.
|
||||
- **The parity convention has no automated check.** I kept all three files in sync by hand because
|
||||
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
|
||||
a future edit can still drift them silently.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: docs/pihole-ha-verified
|
||||
title: PiHole HA is VERIFIED on live prod — including a failover drill with zero dropped DNS queries
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**No manifests changed. This entry exists to correct the log.** The three PiHole/Plex entries merged
|
||||
earlier today each say, correctly at the time, that nothing had been reconciled and that the outcome
|
||||
was unverified:
|
||||
|
||||
- `decisions/2026-10-04-pihole-declare-state.md` — "Nothing here has been reconciled."
|
||||
- `decisions/2026-10-04-pihole-multi-replica.md` — "Nothing has been reconciled. No StatefulSet
|
||||
exists, no second replica has ever run, the anti-affinity has never been evaluated..."
|
||||
|
||||
All three are now **merged, reconciled and measured**, so those sentences are stale in exactly the way
|
||||
§2b of CLAUDE.md warns about — a reader who found them would conclude the work is unproven. This entry
|
||||
is the current state; it does not edit the earlier files (separate files, nothing to merge).
|
||||
|
||||
Delivered and live as of 2026-10-04, from PRs #43 (plex tcp/32400), #44 (declare PiHole state) and
|
||||
#45 (PiHole 2 replicas + the mountPath bug):
|
||||
|
||||
- PiHole is a **2-replica StatefulSet**, one pod per node, with per-replica Longhorn volumes, a
|
||||
PodDisruptionBudget, and an `up` SLI that follows the rendered workload kind.
|
||||
- PiHole's admin password and adlists are **declared in git** and reproduce onto a fresh volume.
|
||||
- PiHole's config PVC is mounted where PiHole actually writes (`/etc/pihole`), which it was not before.
|
||||
|
||||
## What I verified
|
||||
|
||||
All measured against live prod on 2026-10-04, after the merge of #45 (`dce20b7`).
|
||||
|
||||
**Reconcile.** `GitRepository` artifact `main@sha1:dce20b7`; `apps-wave-0`
|
||||
`lastAppliedRevision=main@sha1:dce20b7`, `Ready=True/ReconciliationSucceeded`. It completed in roughly
|
||||
**90 seconds**, well inside the Kustomization's `timeout: 5m` — the risk flagged in the multi-replica
|
||||
entry ("whether `pihole -g` finishes inside the 5 m timeout") did **not** materialise. A transient
|
||||
`Ready=False/DependencyNotReady: platform-config` seen mid-flight was the dependency cascade, not a
|
||||
failure; `platform-config` was already `Ready=True` at the new revision moments later.
|
||||
|
||||
**Topology and objects.**
|
||||
|
||||
| Measured | Value |
|
||||
|---|---|
|
||||
| pods / nodes | `pihole-0` → **k3s-prod-3**, `pihole-1` → **k3s-prod-2** (2/2 Running each) — the required anti-affinity was satisfied |
|
||||
| per-replica claims | `config-pihole-0`, `config-pihole-1` — both `Bound`, 2Gi |
|
||||
| old `pihole-config` PVC | **pruned** (it held only `lost+found`, so nothing was lost) |
|
||||
| old `pihole` Deployment | **pruned** — `No resources found in pihole namespace` |
|
||||
| PodDisruptionBudget | `minAvailable 1`, `disruptionsAllowed 1`, `currentHealthy 2` |
|
||||
|
||||
**The mountPath bug is fixed.** `df` inside both pods reports `/etc/pihole` on
|
||||
`/dev/longhorn/pvc-...` with **6.9 M used** — previously it was on `overlay` while the Longhorn volume
|
||||
sat unused at `/data/config`. `backup.policy: daily-offcluster` now has real content to back up
|
||||
instead of reporting success over an empty volume.
|
||||
|
||||
**Phase 1's declarations reproduce onto a FRESH volume — the point of doing it first.** Both replicas
|
||||
booted with an empty `/etc/pihole`:
|
||||
|
||||
- **72,234 gravity domains** on each, rebuilt from the adlist declared in `catalog/pihole.yaml`.
|
||||
- **The declared SOPS secret authenticates on both pods.** `POST /api/auth` using the container's own
|
||||
`FTLCONF_webserver_api_password` returned `"valid":true` on `pihole-0` **and** `pihole-1` — so
|
||||
Secret → env → FTL's own hashing → working login, identically on both. (The secret's value was never
|
||||
printed; an earlier reading of `pwhash` as "unset" was a quoting bug in the check, disproven by an
|
||||
empty password being rejected with `password incorrect` and then by this positive test.)
|
||||
|
||||
**User-facing behaviour through the VIP (192.168.2.23), from a LAN host.**
|
||||
|
||||
plex.home.keithjackson.dev -> 192.168.2.20 split-horizon intact
|
||||
home.keithjackson.dev -> 192.168.2.20
|
||||
unifi.home.keithjackson.dev -> 192.168.2.21
|
||||
google.com -> 142.250.115.102 recursion to 8.8.8.8 works
|
||||
doubleclick.net -> 0.0.0.0 blocking LIVE in FTL, not just in the DB
|
||||
googleadservices.com -> 0.0.0.0
|
||||
12 consecutive queries -> 12/12 answered
|
||||
|
||||
**THE FAILOVER DRILL — observed by the operator, 2026-10-04.** A single StatefulSet replica was
|
||||
deleted. It was recreated in **~30 seconds**, and **DNS never dropped a request** throughout. This is
|
||||
the first direct evidence that the replica count delivers what it was added for, rather than merely
|
||||
rendering correctly.
|
||||
|
||||
**The SLI is not inert, and re-deriving it mattered.** Measured in Prometheus:
|
||||
`kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}` returns **1 series,
|
||||
value 2**; `kube_deployment_status_replicas_available{namespace="pihole"}` now returns **0 series**;
|
||||
and Prometheus has `pihole-down` loaded as `state=inactive health=ok` with the StatefulSet expression.
|
||||
Had the rule stayed Deployment-shaped, its `or absent(...)` branch would have matched the now-absent
|
||||
series and **fired permanently** — a standing false page rather than silence. That is the concrete
|
||||
payoff of defaulting `livenessWorkload.kind` from the rendered kind.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **NODE failure, as opposed to pod failure.** The drill deleted a pod, which does not exercise the
|
||||
`tolerationSeconds: 300` path at all — that timer only applies when a node goes NotReady. The
|
||||
mechanism protecting DNS is the same one just proven (the surviving replica plus
|
||||
`externalTrafficPolicy: Cluster` on the DNS LoadBalancers), so user-visible impact should likewise be
|
||||
nil; what the 300 s still delays is the *replacement* replica, and during that window there is no
|
||||
redundancy left. Untested. Pulling power from a node is the only real test.
|
||||
- **DNS during the cutover itself.** I was not sampling resolution while #45 reconciled, so whether
|
||||
there was a brief gap between the Deployment being pruned and the StatefulSet becoming Ready is
|
||||
**unmeasured**. The drill above covers the steady-state failover case, not that one-off transition.
|
||||
- **Two-node loss.** The hard anti-affinity means `replicas: 2` needs two schedulable nodes; losing
|
||||
two would leave a replica `Pending` and, with `wait: true`, stall the app tier. Reasoned, not
|
||||
exercised — and it coincides with etcd quorum loss on a 3-node control plane.
|
||||
- **Longhorn capacity headroom.** Two 2Gi claims at 3 replicas each is ~12Gi where there was ~6Gi.
|
||||
Both claims are `Bound` and healthy; free space was not checked.
|
||||
- **Query history is now split per replica** and the admin UI shows only whichever pod answered.
|
||||
Adlists and password are identical, so blocking and auth are consistent; the *reporting* is not, and
|
||||
whether that is acceptable long-term is the operator's call.
|
||||
- **The residual risk is unchanged, and it is the larger one.** Replicas address node failure. LAN DNS
|
||||
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||
outside the cluster carrying the same `address=` lines removes that dependency. Considered and not
|
||||
chosen (operator decision, 2026-10-04).
|
||||
- **Plex remote access (#43) is NOT covered by this entry.** Its two manual steps (the WAN tcp/32400
|
||||
forward and Plex's "manually specify public port") and the outside-in path remain as
|
||||
`decisions/2026-10-03-plex-remote-access-l4.md` describes them. The operator reported it working;
|
||||
I have not measured it from off-LAN.
|
||||
- **`--emit-waves` drift is still open.** Re-running it still wants to onboard `vm-demo.yaml` and
|
||||
`keycloak.yaml`, both listed as intentionally not onboarded in `ci/render-golden.sh`. Unfixed; see
|
||||
`decisions/2026-10-03-plex-remote-access-l4.md`.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/pihole-multi-replica
|
||||
title: PiHole runs two replicas on different nodes — and its config PVC was mounted where PiHole never writes
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Phase 2 of making LAN DNS survive a node failure (phase 1:
|
||||
`decisions/2026-10-04-pihole-declare-state.md`). PiHole was one pod on one node, which for a
|
||||
whole-house resolver cost more than "until it reschedules" — measured 2026-10-04:
|
||||
|
||||
| Failure | Recovery |
|
||||
|---|---|
|
||||
| the node running the pod dies | ~40s to mark NotReady + `tolerationSeconds: 300` before eviction + reschedule + a Longhorn RWO force-detach → **roughly 6-10 minutes with no LAN DNS** |
|
||||
| the node holding the `pihole-dns-*` L2 lease dies | Cilium re-elects an announcer, tens of seconds |
|
||||
|
||||
Those were **different** nodes (lease on k3s-prod-2, pod on k3s-prod-1), so two of three nodes were
|
||||
single points of failure for LAN DNS with wildly different blast radii.
|
||||
|
||||
**A new schema field, because this is intent and not a primitive.** `availability.replicas`
|
||||
(schema **1.10.0**, mechanism in `schemas/renderer-contract.md` `### availability`). `1` or absent
|
||||
renders a Deployment exactly as before — every pre-existing golden is byte-identical, verified. `>1`
|
||||
renders, together:
|
||||
|
||||
- a **StatefulSet** instead of a Deployment. This is forced, not preferred: a longhorn PVC is
|
||||
**ReadWriteOnce**, and one RWO claim cannot attach on two nodes, so a 2-replica Deployment does not
|
||||
degrade — the second pod never schedules.
|
||||
- longhorn storage as **per-replica `volumeClaimTemplates`** (`config-pihole-0`, `config-pihole-1`);
|
||||
the standalone PVC is no longer emitted, since it would be an orphan nothing mounts. A `bulk`
|
||||
entry stays a standalone PVC — it is a shared static NFS export, not per-replica state.
|
||||
- **required** `podAntiAffinity` on `kubernetes.io/hostname`. `preferred` would let both replicas
|
||||
land on one node and deliver none of the intended tolerance while reporting healthy.
|
||||
- `podManagementPolicy: Parallel`, so a cold start costs the max of the replicas' first-boot work
|
||||
rather than the sum — `apps-wave-0` has `timeout: 5m`.
|
||||
- a **PodDisruptionBudget**, `minAvailable: 1`, so a node drain cannot take the last replica.
|
||||
- `observability.livenessWorkload.kind` defaulting to `StatefulSet`, so the `up` SLI reads
|
||||
`kube_statefulset_status_replicas_ready`. A Deployment-shaped rule against a StatefulSet
|
||||
`absent()`s forever — an alert that can never fire.
|
||||
|
||||
`catalog/pihole.yaml` sets `replicas: 2`, not 3: two tolerates any single node loss and leaves a
|
||||
spare node for the evicted replica under the hard anti-affinity. Three would pin one per node with no
|
||||
spare, so a single node failure would leave a replica permanently `Pending` and — `apps-wave-0` being
|
||||
`wait: true` — stall the app tier for as long as the node was down.
|
||||
|
||||
### The bug found on the way, and fixed here
|
||||
|
||||
**PiHole's config PVC was mounted at a path PiHole never writes, so it held nothing and all of its
|
||||
state was ephemeral.** With no explicit `mountPath` the renderer defaults to `/data/<name>`; PiHole
|
||||
keeps everything in `/etc/pihole`. Measured on live prod 2026-10-04: `/etc/pihole` (7.1 M —
|
||||
`gravity.db`, `pihole.toml`, the query DB) was on the **overlay** filesystem, while the 2 Gi Longhorn
|
||||
volume sat at `/data/config` holding `lost+found` and 24 K of nothing. Consequences, all live until
|
||||
now:
|
||||
|
||||
- every restart silently discarded PiHole's entire state and re-downloaded 72 k gravity domains;
|
||||
- `backup.policy: daily-offcluster` has been faithfully backing up an **empty** volume, which is
|
||||
worse than no backup because it reports success;
|
||||
- it also means phase 1 was understated: that state was not merely undeclared, it was **ephemeral**.
|
||||
|
||||
Same class of bug `catalog/plex.yaml` documents for its own `config` entry. Fixed in this branch
|
||||
rather than its own because with the wrong path the new per-replica volumes would be two 2 Gi
|
||||
Longhorn volumes (×3 Longhorn replicas each) holding only `lost+found` while all state stayed
|
||||
ephemeral — paying for persistence and getting none. It also removes this change's main operational
|
||||
risk: with `gravity.db` persisted, a restart is cheap instead of re-downloading 72 k domains inside a
|
||||
wave with a 5 m timeout.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Back-compatibility, the thing most likely to break quietly:** after the renderer change and
|
||||
before `catalog/pihole.yaml` declared anything, the canonical
|
||||
`render.py --all catalog -o apps --wave-layout --no-image-automation` produced **zero diff** across
|
||||
the whole `apps/` tree. `render-golden` passes.
|
||||
- **The new behaviour is pinned by ~20 new `render.py --selftest` assertions** (so `ci/render-golden.sh`
|
||||
hard-fails on a regression, and no `conformance/**` change was needed): `replicas: 1` still renders a
|
||||
Deployment + standalone PVC + no PDB; `replicas: 2` renders a StatefulSet and **no** standalone
|
||||
longhorn PVC, carries the replica count, omits `strategy`, sets `Parallel` and `serviceName`, names
|
||||
the claim template exactly as the mounted volume, keeps RWO/longhorn on it, does **not** duplicate
|
||||
that volume in `pod.volumes`, still mounts it on the container, renders required hostname
|
||||
anti-affinity, renders exactly one `minAvailable: 1` PDB, and switches the SLI to
|
||||
`kube_statefulset_status_replicas_ready`. A mixed longhorn+bulk case asserts the bulk share stays a
|
||||
standalone PVC and an ordinary pod volume.
|
||||
- **Rendered pihole output, read back:** `replicas: 2`, `serviceName: pihole`,
|
||||
`podManagementPolicy: Parallel`, no `strategy`, claim template `config` (RWO/longhorn/2Gi),
|
||||
anti-affinity `kubernetes.io/hostname`, `pod.volumes: []`, mount `config -> /etc/pihole`,
|
||||
PDB `minAvailable: 1`, SLI `kube_statefulset_status_replicas_ready`.
|
||||
- **The `/etc/pihole` mount is safe, evidenced from the image's own init rather than assumed:**
|
||||
`/usr/bin/start.sh` is PID 1 and runs as **uid 0**; its line 5 is
|
||||
`chown -R pihole:pihole /etc/pihole/ /var/log/pihole/`, so a fresh root-owned volume has its
|
||||
ownership fixed before anything else starts (**no `fsGroup` needed, and one would be inert**); it
|
||||
calls `migrate_gravity`, whose `migrate_to_database()` creates the gravity database when
|
||||
`[ ! -e "$gravityDBfile" ]`; FTL writes `pihole.toml` itself from the `FTLCONF_*` env; and the
|
||||
image's own default for `files.macvendor` was moved **out** of that directory, which is what a
|
||||
maintainer does to a path they expect to be a volume.
|
||||
- **The PVC really is unused:** `df` showed `/etc/pihole` on `overlay` and
|
||||
`/dev/longhorn/pvc-849edb86-...` on `/data/config`; `ls -A /data/config` → `lost+found` only.
|
||||
- **The gate caught a real regression and it is fixed.** The first run came back **rc 1**:
|
||||
`alert-rules-unit-tested` failed because `apps/wave-0/tests/app-slis.test.yaml` fed pihole a
|
||||
`kube_deployment_status_replicas_available` series and expected a `deployment: pihole` label. Three
|
||||
input series, the firing expectation and the file header were updated;
|
||||
`platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml` → **SUCCESS** (6 rules).
|
||||
- **Schema:** `check-jsonschema` passes over all of `catalog/*.yaml`.
|
||||
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two environmental skips —
|
||||
`gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE`
|
||||
unset). **Not green; reported as rc 3.**
|
||||
- **No sensitive paths touched**, so unlike the previous two branches this one needs no
|
||||
`/approve-override` — confirmed against `infra/gitea/approval-policy.json`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Nothing has been reconciled.** No StatefulSet exists, no second replica has ever run, the
|
||||
anti-affinity has never been evaluated by the scheduler, the PDB has never been consulted, and the
|
||||
`/etc/pihole` mount has never been exercised on a real pod. Everything above is the rendered
|
||||
artifact plus the image's own source.
|
||||
- **THE ROLLOUT WILL BRIEFLY DROP LAN DNS, and I have not measured for how long.** `postStart` must
|
||||
complete before a container is reported Running, so a pod is not Ready — and the Service has no
|
||||
endpoints — until phase 1's adlist hook finishes. On this transition both replicas start with an
|
||||
empty `/etc/pihole`, so the hook finds a fresh `gravity.db` with zero adlists and runs a real
|
||||
`pihole -g` (72 k domains, network download) before either pod becomes Ready. **Do this at a quiet
|
||||
time.** Two mitigating facts: every pihole restart has *already* paid this cost (state was
|
||||
ephemeral), and after this change `gravity.db` persists so later restarts should not. If it turns
|
||||
out slow, backgrounding the gravity rebuild inside the hook would decouple readiness from it, at
|
||||
the cost of a window where DNS resolves but does not block — not done here.
|
||||
- **Whether `pihole -g` finishes inside `apps-wave-0`'s 5 m `timeout`.** `Parallel` was chosen to
|
||||
make this the max rather than the sum of two first boots, but the real duration is unmeasured (it
|
||||
was stubbed during phase 1's testing). If the wave times out, the Kustomization reports failed even
|
||||
though the pods may come up seconds later.
|
||||
- **The old PVC's deletion.** `apps-wave-0` has `prune: true`, so removing `pihole-config` from the
|
||||
rendered output should delete it. That destroys nothing (it holds `lost+found`), but I have not
|
||||
observed the prune, and I did not check whether Longhorn retains the backing volume afterwards.
|
||||
- **The transition overlap.** For a moment the pruned Deployment's pod and the new StatefulSet pods
|
||||
may both match the Service selector. Both are configured identically from git so this should be
|
||||
benign — unobserved.
|
||||
- **Longhorn capacity.** Two 2 Gi claims at 3 Longhorn replicas each is ~12 Gi of real storage where
|
||||
there was ~6 Gi. Not checked against free space.
|
||||
- **Whether two independent query databases are acceptable to the operator.** Stats and query history
|
||||
now split per replica, and the admin UI shows only the pod that answered. Adlists and password are
|
||||
identical (phase 1), so blocking and auth are consistent; the *reporting* is not.
|
||||
- **The residual risk is unchanged and is the larger one.** Replicas address node failure. LAN DNS
|
||||
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||
outside the cluster carrying the same `address=` lines removes that. Considered and not chosen
|
||||
(operator decision, 2026-10-04).
|
||||
- **`tolerationSeconds: 300` is untouched.** With two replicas the surviving one serves immediately, so
|
||||
the 5-minute eviction delay no longer gates recovery — but it still delays the *replacement* replica,
|
||||
and during that window there is no redundancy left.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/plex-advertise-plexdirect
|
||||
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
|
||||
shows "not authorized"; connecting straight to the VIP + port works.
|
||||
|
||||
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
|
||||
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
|
||||
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
|
||||
|
||||
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
|
||||
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
|
||||
|
||||
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
|
||||
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
|
||||
for every client. Not theoretical: Plex's log carries a continuous storm of
|
||||
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
|
||||
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
|
||||
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
|
||||
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
|
||||
|
||||
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
|
||||
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
|
||||
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
|
||||
clients reported "not authorized".
|
||||
|
||||
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
|
||||
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
|
||||
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
|
||||
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
|
||||
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
|
||||
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
|
||||
placeholder** — dev is decommissioned and has no claimed server, and
|
||||
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
|
||||
and be correctly shaped.
|
||||
- **`clusters/prod/kustomization.yaml`:** prod value
|
||||
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
|
||||
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
|
||||
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
|
||||
lines, nothing else.
|
||||
|
||||
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
|
||||
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
|
||||
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
|
||||
halves of one address and **nothing checks that they agree** — noted in both files.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK it.**
|
||||
It needs the operator's `/approve-override <reason>` posted as a PR **review** of type "Comment".
|
||||
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
|
||||
account holds `owner`, so the prohibition is a rule rather than a limit.
|
||||
|
||||
## What I verified
|
||||
|
||||
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
|
||||
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
|
||||
and the token was reported only as `<PRESENT,len=22>`.
|
||||
|
||||
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
|
||||
rests on:
|
||||
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
|
||||
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
|
||||
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
|
||||
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
|
||||
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
|
||||
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
|
||||
- **The certificate, read off the live listener:** exactly one SAN,
|
||||
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
|
||||
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
|
||||
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
|
||||
— all four unreachable from a LAN client or from plex.tv.
|
||||
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
|
||||
session — the documented fallback when nothing is directly publishable.
|
||||
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
|
||||
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
|
||||
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
|
||||
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
|
||||
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
|
||||
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
|
||||
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
|
||||
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
|
||||
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
|
||||
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
|
||||
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
|
||||
rules and no CCNPs exist anywhere in the cluster.
|
||||
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
|
||||
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
|
||||
transcoder segment numbers).
|
||||
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
|
||||
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
|
||||
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
|
||||
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**. The rc 3 is the two known environmental skips — `gitea-settings` (no local
|
||||
`SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
|
||||
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
|
||||
the rendered diff is exactly two lines and no other service's manifest moved.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
|
||||
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
|
||||
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
|
||||
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
|
||||
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
|
||||
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
|
||||
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
|
||||
the server as remotely unreachable even when local clients work.
|
||||
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
|
||||
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
|
||||
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
|
||||
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
|
||||
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
|
||||
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
|
||||
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
|
||||
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
|
||||
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
|
||||
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
|
||||
typed them while debugging. I did not alter live preferences; the env var will overwrite
|
||||
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
|
||||
to git and will **silently discard** anything typed there since.
|
||||
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
|
||||
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
|
||||
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
|
||||
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
|
||||
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
|
||||
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
|
||||
way the fix still fails.
|
||||
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
|
||||
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
|
||||
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
|
||||
something restarted Plex inside the container. Unexplained and not investigated.
|
||||
@@ -45,17 +45,34 @@ hubble:
|
||||
enabled: true
|
||||
ui:
|
||||
enabled: true
|
||||
# Static flow export -> cilium-agent stdout. Kept EQUAL to the Flux values
|
||||
# (platform/cilium/helmrelease.yaml), which carry the full rationale: it records the PRE-SNAT
|
||||
# client address for Plex's raw tcp/32400, which Plex itself cannot log because every
|
||||
# LoadBalancer Service here is externalTrafficPolicy: Cluster and the VIP lease holder SNATs the
|
||||
# client. Present here so a BOOTSTRAP render does not differ from what Flux reconciles -- a
|
||||
# mismatch would show up as a permanent Helm diff and an avoidable agent rollout. Scoped by PORT,
|
||||
# not namespace: the flow carrying the real client address does not match a namespace filter.
|
||||
export:
|
||||
static:
|
||||
enabled: true
|
||||
filePath: stdout
|
||||
allowList:
|
||||
- '{"destination_port":["32400"]}'
|
||||
- '{"source_port":["32400"]}'
|
||||
|
||||
# Ingress VIP announcement on the NAT LAN. IP pool / CiliumL2AnnouncementPolicy
|
||||
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
|
||||
l2announcements:
|
||||
enabled: true
|
||||
|
||||
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
|
||||
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
|
||||
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
|
||||
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
|
||||
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
|
||||
cgroup:
|
||||
autoMount:
|
||||
enabled: false
|
||||
hostRoot: /run/cilium/cgroupv2
|
||||
hostRoot: /sys/fs/cgroup
|
||||
|
||||
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
||||
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
||||
|
||||
@@ -101,19 +101,92 @@ spec:
|
||||
enabled: true
|
||||
ui:
|
||||
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
|
||||
# --- STATIC FLOW EXPORT: persist the PRE-SNAT client address for Plex (2026-10-04) ---
|
||||
# [parity] MUST equal Ansible: hubble.export.static.{enabled,filePath,allowList}
|
||||
#
|
||||
# WHY THIS EXISTS. Plex's own log cannot show who connected. Every LoadBalancer Service in
|
||||
# this cluster is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the
|
||||
# VIP SNATs the client before forwarding to the backend pod. Measured 2026-10-04: Plex had
|
||||
# logged 3,214 requests from `10.0.1.x` (the lease holder k3s-prod-3's own address, Cilium
|
||||
# identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No X-Forwarded-For
|
||||
# either -- it serves raw tcp/32400, not HTTP through the Gateway.
|
||||
#
|
||||
# THE ADDRESS IS NOT LOST, ONLY UNRECORDED. Cilium observes the flow at ingress, BEFORE its
|
||||
# own translation, so Hubble already has the true source. Measured on the lease holder:
|
||||
# 192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
|
||||
# 10.0.1.78 -> 10.0.0.135 (the same traffic, post-SNAT -- what Plex sees)
|
||||
# What was missing is RETENTION: the ring buffer was 4095/4095 flows at ~303 flows/s, i.e.
|
||||
# about THIRTEEN SECONDS of history, with hubble metrics disabled and no exporter configured.
|
||||
#
|
||||
# WHY NOT FIX THE SNAT INSTEAD. `externalTrafficPolicy: Local` would preserve the address in
|
||||
# Plex's own log, but cilium 1.20's L2 announcer is explicitly incompatible with it -- upstream
|
||||
# docs for this exact version: "incompatible with the externalTrafficPolicy: Local on services
|
||||
# as it may cause service IPs to be announced on nodes without pods causing traffic drops",
|
||||
# and "L2 announcements isn't currently aware of this setting". Live proof that this is not
|
||||
# theoretical: the VIP is announced by k3s-prod-3 while the pod runs on k3s-prod-1, so the
|
||||
# flip would black-hole tcp/32400 immediately. Making it safe would mean pinning BOTH the pod
|
||||
# and the announcement to one node and giving up Plex's ability to reschedule -- a real
|
||||
# availability loss to buy a log field. (It is the same trap platform/crafty/service-l4.yaml
|
||||
# documents from an 8-day Minecraft outage.) DSR was also rejected: routing-mode here is
|
||||
# vxlan, not native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO
|
||||
# router's stateful NAT on the remote path.
|
||||
#
|
||||
# `filePath: stdout` RATHER THAN A FILE, deliberately. Grafana Alloy already runs as a
|
||||
# DaemonSet tailing every pod's logs into Loki (platform/monitoring/alloy.yaml), so stdout
|
||||
# reaches Loki with NO new hostPath mount, no file-rotation handling and no Alloy change.
|
||||
# Upstream documents it: "To get Hubble flows directly exported to the logs instead of written
|
||||
# to a rotated file, `stdout` can be defined as `hubble-export-file-path`." fileMaxSizeMb /
|
||||
# fileMaxBackups are therefore left at chart defaults and do not apply.
|
||||
# Query: {namespace="kube-system", app="cilium"} |= "32400"
|
||||
#
|
||||
# SCOPED BY PORT, NOT BY NAMESPACE -- and that is a measured decision, not a preference. A
|
||||
# `--to-namespace plex` query returned ONLY the post-SNAT flows; the flow carrying the real
|
||||
# client address did NOT match it, because at the point of observation the destination
|
||||
# identity is not yet resolved to the plex pod. Filtering by namespace would therefore drop
|
||||
# exactly the record this change exists to capture. Port it is.
|
||||
#
|
||||
# Both directions are listed so the return path is captured too. Volume is small (a few
|
||||
# hundred to a few thousand lines a day); if it ever becomes noisy, narrow it with
|
||||
# `fieldMask` rather than by removing a filter.
|
||||
export:
|
||||
static:
|
||||
enabled: true
|
||||
filePath: stdout
|
||||
allowList:
|
||||
- '{"destination_port":["32400"]}'
|
||||
- '{"source_port":["32400"]}'
|
||||
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
|
||||
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
||||
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
||||
l2announcements:
|
||||
enabled: true
|
||||
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
|
||||
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
|
||||
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
|
||||
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
|
||||
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
|
||||
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
|
||||
#
|
||||
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
|
||||
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
|
||||
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
|
||||
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
|
||||
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
|
||||
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
|
||||
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
|
||||
# only the agent. Every other host-netns process (the k3s apiserver in
|
||||
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
|
||||
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
|
||||
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
|
||||
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
|
||||
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
|
||||
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
|
||||
# FAILED from the host, same node, same netns. Full write-up:
|
||||
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
|
||||
#
|
||||
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
|
||||
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
|
||||
# stays off because the mount it would create already exists.
|
||||
cgroup:
|
||||
autoMount:
|
||||
enabled: false
|
||||
hostRoot: /run/cilium/cgroupv2
|
||||
hostRoot: /sys/fs/cgroup
|
||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||
|
||||
@@ -38,14 +38,28 @@ hubble:
|
||||
enabled: true
|
||||
ui:
|
||||
enabled: true
|
||||
# [parity] static flow export -> cilium-agent stdout -> Alloy -> Loki. Records the PRE-SNAT
|
||||
# client address for Plex's raw tcp/32400, which Plex's own log cannot show (every LB Service here
|
||||
# is externalTrafficPolicy: Cluster, so the VIP lease holder SNATs the client). Scoped BY PORT,
|
||||
# not namespace: the flow carrying the real client IP does not match a namespace filter. Full
|
||||
# rationale, measurements and the rejected alternatives are in helmrelease.yaml.
|
||||
export:
|
||||
static:
|
||||
enabled: true
|
||||
filePath: stdout
|
||||
allowList:
|
||||
- '{"destination_port":["32400"]}'
|
||||
- '{"source_port":["32400"]}'
|
||||
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
||||
l2announcements:
|
||||
enabled: true
|
||||
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
|
||||
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
|
||||
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
|
||||
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
|
||||
cgroup:
|
||||
autoMount:
|
||||
enabled: false
|
||||
hostRoot: /run/cilium/cgroupv2
|
||||
hostRoot: /sys/fs/cgroup
|
||||
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
|
||||
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
|
||||
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
||||
|
||||
@@ -161,6 +161,41 @@ platform must render, and cites the requirement each mapping satisfies. Changing
|
||||
- **Requirements:** **R-STATE-01** (rebuild survival), **R-STATE-02** (Longhorn replication),
|
||||
**R-STATE-04** (bulk tier). (docs/04)
|
||||
|
||||
### `availability` (`replicas`) — workload kind, per-replica volumes, spreading, budget
|
||||
|
||||
**Schema 1.10.0.** Availability INTENT. It names no Kubernetes object, no topology key and no
|
||||
budget — exactly like `hardware.gpu`, and for the same reason: the mechanism below should be
|
||||
replaceable without a catalog edit.
|
||||
|
||||
- **Absent, or `replicas: 1`** → a **Deployment**, standalone PVC(s), `strategy: Recreate` when there
|
||||
is storage, no PodDisruptionBudget. Byte-identical to pre-1.10.0 output, so every existing app is
|
||||
untouched.
|
||||
- **`replicas: N > 1`** → all of the following, together:
|
||||
| Rendered | Why |
|
||||
|---|---|
|
||||
| **StatefulSet** instead of Deployment | A longhorn PVC is **ReadWriteOnce**. One RWO claim cannot attach on two nodes, so a multi-replica Deployment does not degrade — the second pod never schedules. |
|
||||
| longhorn storage becomes **`volumeClaimTemplates`** | Gives each replica its own claim (`<vol>-<svc>-<ordinal>`, e.g. `config-pihole-0`). The template's `metadata.name` **must** equal the pod volume name the container mounts. The standalone PVC is then **not** emitted — it would be an orphan nothing mounts. |
|
||||
| `bulk` storage stays a **standalone PVC** | A bulk entry is a static, pre-bound NFS PV over data that already exists outside the cluster and is shared read-only. Per-replica copies are neither possible nor wanted. |
|
||||
| **required** `podAntiAffinity` on `kubernetes.io/hostname` | `preferred` would let every replica land on one node and deliver none of the intended node-failure tolerance, while every object still reported healthy. |
|
||||
| `podManagementPolicy: Parallel` | `OrderedReady` serialises first-boot work across replicas; `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between replicas to protect. |
|
||||
| **PodDisruptionBudget**, `minAvailable: 1` | Stops a *voluntary* disruption (node drain, upgrade) taking the last replica. It does nothing about a node dying — that is what the replica count and anti-affinity are for. Never rendered at `replicas: 1`, where `minAvailable: 1` would block every drain of that node forever. |
|
||||
| `observability.livenessWorkload.kind` defaults to **`StatefulSet`** | The `up` SLI reads kube-state-metrics by workload kind. A Deployment-shaped rule against a StatefulSet `absent()`s forever — an alert that can never fire. Deriving the default means bumping the replica count cannot leave a stale rule behind. |
|
||||
|
||||
**Two costs, both deliberate, neither hidden:**
|
||||
|
||||
1. **N replicas need N schedulable nodes.** On a 3-node cluster, losing two nodes leaves a replica
|
||||
`Pending`; since `apps-wave-0` is `wait: true` and `apps-wave-1` `dependsOn` it, that **stalls the
|
||||
app tier**. That coincides with having lost etcd quorum on a 3-node control plane, so nothing is
|
||||
reconciling at that point anyway — the stall is a symptom, not a new failure mode.
|
||||
2. **Only declare `> 1` for a service whose on-disk state is reproducible from git.** A replica with
|
||||
a fresh volume comes up serving whatever it can bootstrap itself and nothing else. For pihole that
|
||||
prerequisite was done first — the admin password and the adlists were declared in
|
||||
`decisions/2026-10-04-pihole-declare-state.md` — because otherwise a second replica would have
|
||||
served DNS while blocking nothing, with a different password.
|
||||
|
||||
**Requirements:** R-PLAT-02 (the workload), R-STATE-01/02 (per-replica replicated volumes),
|
||||
R-OBS-01 (the SLI must follow the rendered kind).
|
||||
|
||||
### `hardware` (`gpu`) — host device capability
|
||||
- **The field is INTENT, never MECHANISM.** `hardware.gpu` is an **enum of capability classes**
|
||||
(today: `intel-quicksync`), not a device path, not a hostPath, not a resource request. The
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||
"title": "ServiceDefinition",
|
||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. It sets NO podSecurityContext.supplementalGroups: measured in-container on prod 2026-09-30, the pms-docker cont-init hook adds the app user to the device's group inside the container and `s6-setuidgid` then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied, so such an entry would be inert and misleading. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.md already said `internal` renders no public DNS record; nothing enforced it, and six internal services -- jackett, pihole, qbittorrent, radarr, semaphore, sonarr -- were publishing `<name>.home.keithjackson.dev` into the PUBLIC Cloudflare zone answering with the RFC1918 Gateway VIP, which published the internal subnet and a full service inventory and made each name a DNS-rebind primitive. Enforced on every gate run by conformance/tests/l0-static/internal-services-publish-no-public-dns). 1.6.0 (splits `observability.metrics` from WHERE metrics live: adds optional `metricsEndpoint` (port/name/path/interval) and optional `livenessWorkload` (kind/name). `metrics: true` previously rendered a ServiceMonitor hardcoded to a port named `metrics` that no rendered Service ever defined, so all twelve app monitors discovered ZERO targets and every `up{job=...}` SLI was structurally inert on prod; a monitor is now rendered only from a declared `metricsEndpoint`, which also renders the Service port and the monitoring-namespace ingress allow, and the `up` SLI is expressed against kube-state-metrics -- a signal that exists for every workload without a per-app exporter. `livenessWorkload` names the object whose readiness IS the service, for the case where the running workload is not the one this SD renders). 1.5.0 (adds optional `inClusterClients` — additional in-cluster clients, named by NAMESPACE, admitted to a declared port of this service DIRECTLY, bypassing the shared Gateway. `ingressRoutes` admits only the Gateway proxy identity and `dependencies` only links catalog services, so a PLATFORM controller dialling a Service ClusterIP had no authorable form at all; that gap is what deadlocked proLine truncated
|
||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.10.0 (adds optional `availability` for the container kind, one member: `availability.replicas`. It is AVAILABILITY INTENT, not a scheduling primitive -- it names no Kubernetes object, no topology key and no budget. 1 (the default) renders a Deployment exactly as before, so every pre-existing app stays byte-identical; >1 means the service must survive the loss of a single node, and the renderer satisfies that with a StatefulSet whose longhorn volumes become per-replica volumeClaimTemplates (a longhorn PVC is ReadWriteOnce and cannot attach on two nodes, so a multi-replica Deployment is UNSCHEDULABLE, not merely suboptimal), REQUIRED podAntiAffinity on kubernetes.io/hostname (preferred would let every replica land on one node and deliver none of the intended tolerance while reporting healthy), podManagementPolicy Parallel, a PodDisruptionBudget of minAvailable 1, and an `up` SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever and can never fire). Added for pihole, where LAN DNS -- a critical network service -- was one pod on one node: measured 2026-10-04, losing the node running it meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 plus node-NotReady detection plus a Longhorn RWO force-detach). Deliberately NOT usable until a service's state is reproducible from git, because a replica with a fresh volume otherwise comes up serving but unconfigured -- for pihole that prerequisite was done first (decisions/2026-10-04-pihole-declare-state.md). Capped at 5: a count above the node count can never schedule under the anti-affinity this implies. Mechanism documented in schemas/renderer-contract.md `### availability`). 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. It sets NO podSecurityContext.supplementalGroups: measured in-container on prod 2026-09-30, the pms-docker cont-init hook adds the app user to the device's group inside the container and `s6-setuidgid` then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied, so such an entry would be inert and misleading. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.mdLine truncated
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "source"],
|
||||
@@ -139,6 +139,21 @@
|
||||
"supplementalGroups": { "type": "array", "items": { "type": "integer" }, "description": "Additional GIDs added to the first process in each container." }
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"type": "object",
|
||||
"description": "Optional availability intent for the container kind: how many replicas of this workload must be running. INTENT ONLY -- it names no Kubernetes object and no scheduling primitive. How the platform satisfies it (workload kind, per-replica volumes, node spreading, disruption budget) is catalog/render.py's business, documented in schemas/renderer-contract.md `### availability`. Absent => replicas 1 and a Deployment, so every pre-existing app renders byte-identical. Ignored for kind=vm (a VM's availability is a different problem with different primitives).",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"type": "integer",
|
||||
"description": "How many replicas must be running. 1 (default) renders a Deployment exactly as before. >1 declares that the service must survive the loss of a single node; the renderer then emits a StatefulSet with per-replica volumeClaimTemplates, required per-node anti-affinity, podManagementPolicy Parallel and a PodDisruptionBudget. Only declare >1 for a service whose on-disk state is reproducible from git -- a replica with a fresh volume comes up serving whatever it can bootstrap itself, and nothing else.",
|
||||
"minimum": 1,
|
||||
"maximum": 5,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"hardware": {
|
||||
"type": "object",
|
||||
"description": "Optional host HARDWARE CAPABILITY the workload needs, for the container kind (docs/13). This field declares INTENT (`what device class this service needs`), never MECHANISM (`which host path to bind-mount`, `which extended resource to request`). That separation is the whole point of the enum: the platform currently satisfies `intel-quicksync` with a /dev/dri hostPath volume and a matching mount on the app container, but a later migration to the Intel GPU device plugin (`gpu.intel.com/i915` resource requests, no hostPath) must be a catalog/render.py change and NOTHING else - no ServiceDefinition may need editing, and no reviewer may have to re-derive per-service device wiring. Do NOT 'simplify' this into a raw device path, a hostPath, or a resource request; that would push a platform implementation detail into the operator-facing contract and make the migration a catalog-wide edit. Absent => no device wiring at all (existing apps render byte-identical). Ignored for kind=vm (a VM gets devices through KubeVirt's own domain.devices).",
|
||||
|
||||
Reference in new issue
Block a user