The 3x 6GB nodes are OOM (k3s-dev-2's kubelet stopped; dev-1/dev-3 <600MB free). KubeVirt runs
a full control plane (~1.5-2GB) for ZERO running VMs — its IaaS conformance (R-PLAT-03/04,
R-NET-01, R-STATE-01) is already certified, so disabling the running pods loses no delivered
capability. Re-enable (uncomment kubevirt in platform + platform-config) once the VMs' RAM is
grown. Manifests retained; reversible.
Root cause the media stack surfaced: the default-deny baseline is DNS-only egress (correct for
R-NET-01 east-west), but the egress CLASS (internet/vpn) had no policy ALLOWING north-south
egress — so qbittorrent/sonarr/radarr couldn't reach the internet at all (VPN downloaders
broken). Add an additive per-app CiliumNetworkPolicy '<app>-allow-internet-egress' with
toEntities:[world] (non-cluster); the egress-class steering + kill-switch (ADR-0015) govern the
PATH (internet=WAN, vpn=tun0). East-west stays denied except declared dependencies; R-NET-01
baseline unchanged. Re-rendered all apps + regenerated L1 goldens. Also fixed jackett + plex
image tags (bad pins -> :latest; Renovate/update policy pins later).
The 39Gi/node Longhorn data disks at 1:1 over-provisioning can't fit the media stack
(plex 20Gi replica failed: 'insufficient storage'). Volumes are thin-provisioned config-only,
so 200% lets the full catalog fit without a disk resize; physical fill risk is low. Also
right-sized plex config (20Gi was excessive for metadata). Prod-grade path: grow the data
disks (WS-STORAGE follow-up).
Add catalog/ ServiceDefinitions for qbittorrent and jackett and their
rendered apps/ manifests, wired into apps/kustomization.yaml.
Both are VPN-class (egress: vpn -> egress-class=vpn pod label steered by the
CiliumEgressGatewayPolicy + host kill-switch, ADR-0015) and internal
forward-auth UIs (oauth2-proxy -> Keycloak, group media-users). Config on
3-replica Longhorn. qBittorrent's bulk downloads volume is DEFERRED pending
OQ-2 (non-provisioning bulk StorageClass stub), mirroring plex/arr.
Traces: R-NET-05/06 (VPN egress + kill-switch), R-IDP-02/06 (forward-auth),
R-STATE-02/03, R-OBS-01, R-UPD-01/03, R-GITOPS-02.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add catalog/plex.yaml (kind: container) and its rendered apps/plex.yaml,
and wire apps/kustomization.yaml. Plex = docs/13 row 1, the catalog's
largest service; validates the public-exposure + config-on-Longhorn path.
- image plexinc/pms-docker (pinned recent stable tag), port 32400
- exposure: public at plex.home.keithjackson.dev; egress: internet
- auth: native (Plex accounts, not Keycloak — docs/13/15 exception)
- config PVC 20Gi on longhorn; off-cluster backup (daily-offcluster) as the
longhorn tier forbids reacquirable under L0 (R-STATE-03)
- observability up-SLI; update channel stable
Bulk media (R-STATE-04) deferred: the `bulk` StorageClass is a documented
stub (OQ-2 unresolved, NFS CSI not installed) so a bulk PVC would leave the
pod Pending. Media mount omitted; attach once bulk provisions.
Traces: R-GITOPS-02, R-NET-01/02/05, R-STATE-01/02/03, R-TLS/R-DNS,
R-OBS-01, R-UPD-01/03, R-IDP-02, R-PLAT-02.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rewrote the probe for the LE-production switch: no embedded staging root, no --cacert
(system CA bundle validates ISRG Root X1), resolves the hostname via in-cluster
split-horizon DNS to the Gateway VIP (the real-client path). PASSES live (9s). Removed the
le-staging-root-ca fixture.
New runnable chainsaw test gateway-ingress-reachable (R-NET-01/02/03): stands up an
exposed-app fixture (namespace default-deny + the renderer's additive gateway-ingress
CiliumNetworkPolicy allowing fromEntities:[ingress] to the declared port 8080 only, a
backend listening on 8080 + an undeclared 9091, and declared/undeclared HTTPRoutes on
the shared Gateway) and proves BEHAVIOR through the REAL Gateway VIP:
* then-reachable-through-gateway -> the Gateway (ingress identity) reaches backend:8080
through default-deny (backend marker returned) [R-NET-02/03]
* then-undeclared-port-denied -> port 9091 (no allow) is NOT reachable through the
Gateway (marker never returned; envoy 503) [R-NET-02]
* then-east-west-still-denied -> a non-ingress in-namespace peer is DROPPED on 8080;
the allow is least-privilege, east-west default-deny intact (hermetic) [R-NET-01]
Verified live: reachable=REACHED (200, marker), undeclared=DENIED, east-west=DENIED.
Self-skipping: the gateway probes classify each attempt REACHED / DENIED / NO-GATEWAY
(curl 000). NO-GATEWAY self-skips (green + note) because a node-local Cilium L7LB/envoy
desync can make pods on an affected node unable to reach the Gateway VIP at all — a
SEPARATE platform fault, not default-deny — so this test never false-fails on it. The
east-west denial is hermetic and always asserts. VIP discovered per-step via a kubectl
output binding (env-agnostic; chainsaw bindings are step-scoped). L2 layer stays gated by
CLUSTER_AVAILABLE in run.sh. chainsaw v0.2.12 quirks honored (scalar/JMESPath asserts,
no readinessProbe under default-deny, step-level finally delete).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Exposed apps got a default-deny NetworkPolicy with ingress: [] (deny all), so the
Cilium Gateway (Envoy, reserved identity `ingress`) could not reach the backend — an
Accepted HTTPRoute was still unreachable. render.py now emits an ADDITIVE, least-
privilege CiliumNetworkPolicy for any ServiceDefinition that renders an HTTPRoute:
ingress:
- fromEntities: [ingress] # the shared Cilium Gateway/Envoy proxy identity
toPorts: [<exposed service port(s)> TCP]
Verified against the live cluster (Cilium 1.16.5): with default-deny in place, Hubble
shows the Gateway proxy reaching pihole as source identity `(ingress)` and the flow
`Policy denied DROPPED` on :80 — so `fromEntities: [ingress]` is exactly the source the
Gateway uses, scoped to the service targetPort. Default-deny stays intact (ingress: []
unchanged); the allow only opens the north-south path (docs/09), east-west stays denied.
- render.py: add r_gateway_ingress_policy(); emit it after dependency policies; selftest
asserts the allow is additive (default-deny ingress stays []), least-privilege (only the
exposed port, only the ingress identity), and absent for non-exposed services.
- L1 goldens updated for the two exposed fixtures (container-workload, egress-class);
default-deny-baseline golden unchanged (no exposure). --selftest + run.sh --layer l1 green.
- apps/pihole.yaml re-rendered (--no-image-automation) to carry the allow.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
First app onboarded through catalog/render.py -> apps/. Added --no-image-automation so apps
deploy before the Flux image controllers exist (L1 goldens keep the default, unchanged).
KNOWN GAP surfaced: exposed apps get default-deny (ingress: []) with no gateway-ingress allow,
so the HTTPRoute path is blocked — renderer/WS-NET follow-up to allow Cilium gateway ingress
to services with an exposure/HTTPRoute.
busybox wget has no --max-redirect flag → it printed usage, so the probe never saw the
302 and R-IDP-01 falsely failed. Grafana verified live: /login/generic_oauth 302s to the
Keycloak homelab authorize endpoint. Use 'wget -S' (dumps the 302 Location before follow).
VMs did not boot on the dev cluster. Three distinct live-verified causes,
all fixed GitOps-side:
1. containerDisk root failed at OCI init:
exec "/container-disk-binary/usr/bin/container-disk": no such file.
KubeVirt v1.9 supplies that binary to the volumerootdisk init container
via a k8s-native `image` volume (VolumeSource.Image), gated by the ALPHA
`ImageVolume` feature gate. k3s v1.31 has it DISABLED, so the apiserver
silently prunes the image volume + its mount -> binary missing -> crash.
(Proved: a server-side dry-run of a pod with an `image` volume drops it.)
Fix: convert the VM roots in all three tests to CDI DataVolumes imported
onto Longhorn PVCs (mounted disks bypass the container-disk/image-volume
path). This is also the spec-required disk model (docs/06, R-STATE-01).
vm-lifecycle now PASSES green live (VMI Running on start + restart).
2. Guest dropped to emergency mode: the containerdisks/fedora:41 image is
UEFI-native (ESP + /boot mounted via systemd GPT auto-discovery); under
KubeVirt's SeaBIOS default the ESP+/boot by-uuid mounts time out ->
local-fs.target fails -> cloud-init runcmd/poweroff never runs.
Fix: boot the VMs with EFI (OVMF, secureBoot:false) in all three tests
and fixtures/vm.yaml. Confirmed live: emergency mode gone with EFI.
3. VMs ran under TCG software emulation (guests ~10x slow, saturating nodes
until one went NotReady and VMs were evicted). Cause: useEmulation:true
in the KubeVirt CR makes virt-controller NOT request devices.kubevirt.io/
kvm, so /dev/kvm is never mounted and qemu falls back to TCG even though
all nodes advertise kvm=1k / hypervisor=true. (Proved: `ls /dev/kvm` in a
virt-launcher compute container = "No such file"; qemu accel = tcg.)
Fix: useEmulation:false so the kvm device is requested and VMs run on real
(nested) KVM. Requires Flux reconcile to take effect live (PM action).
The virt-handler clientcertificates tls.crt error is UNRELATED: node-labeller
(294 labels, hypervisor=true), device plugins, and VMI scheduling all work.
Also raised test exec timeouts to 30m (CDI import + boot) and, in
vm-default-deny, import the root BEFORE applying the default-deny NetworkPolicy
so the CDI importer's egress to quay.io is not severed by the policy.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Grafana's pod predated the grafana-oidc-credentials secret, so generic_oauth started with
an empty client_secret (disabled) → /login/generic_oauth didn't redirect to Keycloak.
Add a podAnnotation to force one roll; bump on future OIDC secret changes.
oauth2-proxy + Grafana OIDC discovery to keycloak.home.keithjackson.dev failed x509
'unknown authority' — the wildcard was LE-staging (untrusted). Prod issuer is registered
+ Ready; switching ACME_CLUSTER_ISSUER dev override staging->prod re-issues a publicly
trusted cert. Needed for functional SSO (R-IDP) + R-TLS-01/03 'trusted'.
NOTE: the trusted-tls conformance test embeds the LE-staging root and must be updated to
the LE production root (ISRG Root X1) / system trust — follow-up.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add dns-internal-resolution (L2 behavioral): an in-cluster Job resolves
keycloak.home.keithjackson.dev via the default cluster resolver and asserts the answer
is the in-cluster Gateway VIP (10.73.0.24x), not a public/off-cluster hairpin. Complements
dns-exposure (R-DNS-02, external record). coverage.py now maps R-DNS-01/R-DNS-03 to this
test (covered); chainsaw v0.2.12 lint clean; strict hygiene gate green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
In-cluster clients resolving a platform hostname over its PUBLIC name got the
off-cluster answer, so pod->keycloak.home.keithjackson.dev hit a path that does not
serve the *.home.keithjackson.dev SNI -> `tls: unrecognized name` (oauth2-proxy OIDC
discovery crash; Grafana generic_oauth would hit the same wall).
Add a GitOps-managed coredns-custom ConfigMap (kube-system) that k3s CoreDNS imports as
a `*.server` block. The template plugin answers the whole home.keithjackson.dev zone with
${GATEWAY_VIP} (the shared Gateway VIP), so pod->public-hostname traffic hairpins through
the Gateway with the correct SNI -> wildcard cert validates. CoreDNS reloads the ConfigMap
live (no pod restart). Wired into platform/kustomization.yaml (Phase 2 network core).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Parameterize the in-cluster split-horizon target as GATEWAY_VIP (R-ENV-02): base
default = dev L2-announced Gateway VIP (10.73.0.240, first IP of the Cilium LB pool).
coredns-custom answers *.home.keithjackson.dev with ${GATEWAY_VIP} so the unified graph
holds across dev/prod via cluster-vars only. Prod overlay carries a TODO to set the prod
LAN VIP alongside the prod L2 pool range when hardware is built.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The renderer's PaaS path (kind: container) had no verifying conformance test — the
R-PLAT-02 token appears only in renderer comments, so a change touching catalog/
surfaced it as an uncovered touched-requirement (blocking the merge gate). Adds the
container render analog of vm-lifecycle (R-PLAT-03): asserts a kind: container
ServiceDefinition renders a Deployment (running the declared image) + a Service,
with the egress-class/auth-mode labels admission requires. Offline render-check.sh,
wrapped by a chainsaw Test for in-cluster runs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Implements the two L1 rendering tests that were failing scaffolds:
- egress-class-render (R-NET-05/06/07): renders vpn + internet fixtures, diffs each
against a committed golden, asserts the vpn steering label is present on vpn and
absent on internet (fail-closed), and that flipping the class changes ONLY the
egress path (R-NET-07). This fixes the --requirements R-NET-05 merge gate.
- default-deny-baseline-render (R-NET-01): asserts every render carries the
namespaced default-deny baseline, shaped deny-all + both directions.
Each test carries a pure-offline render-check.sh (renderer + golden diff, no
cluster). run.sh runs L1 render checks offline and emits a REAL pass/fail JUnit
(docs/14: L1 rendering assertions run in CI without a cluster) instead of skipping
L1 with the cluster-gated layers; the chainsaw-test.yaml wrappers invoke the same
render-check.sh so an in-cluster run executes the identical assertion.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
catalog/render.py compiles a ServiceDefinition into the manifest set defined by
schemas/renderer-contract.md: Namespace + default-deny baseline (R-NET-01),
Deployment/Service or KubeVirt VM + CDI DataVolume (R-PLAT-02/03), PVCs (R-STATE),
per-dependency CiliumNetworkPolicy allows (R-NET-02), egress-class steering label
(R-NET-05), HTTPRoutes on the shared Gateway (R-TLS/R-DNS), ServiceMonitor + SLI
alerts (R-OBS-01), Flux image-automation (R-UPD), auth-mode/backup hints (R-IDP).
Deterministic (golden-diffable) with a built-in --selftest; single dependency
(PyYAML) so the conformance harness stays stdlib-only. Documents the mechanism
choice in catalog/README.md and the script header.
Co-Authored-By: Claude Opus 4.8 <[email protected]>