The offline `kyverno test` CLI cannot resolve the Flux postBuild token
${KYVERNO_VALIDATION_ACTION} in validationFailureAction, so the 4 policies
carrying it failed to load and their result rows reported "Not found" (14/17
cases). Render a throwaway copy of platform/kyverno with the base/prod value
(Enforce) before `kyverno test` in both ci/l0.sh and scripts/run-l0.sh; this
does not change in-cluster enforcement.
Add tests/values.yaml supplying Namespace labels so the offline CLI resolves
namespaceSelector matches -- without it the R-SEC-02/R-NET-05/R-IDP-02 Pod
cases were silently EXCLUDED (vacuous), not evaluated.
Pin kyverno-json 0.0.3 in ci/versions.env (0.0.5 has no upstream release;
0.0.3 is what run-l0.sh and the devcontainer already use).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Bake the conformance harness toolchain into infra/devcontainer so `make -C ci gate`
runs identically here and in CI:
- Containerfile: install kyverno CLI (1.13.4), kyverno-json, chainsaw (0.2.12),
yamllint (1.35.1), check-jsonschema (0.29.4), yq (4.44.6) via new pinned RUN block;
add diffutils + python3-pip; drop stale LIBVIRT_DEFAULT_URI; refresh header for the
Proxmox(bpg)+conformance toolbox; extend /etc/dev-toolchain-versions provenance.
- versions.env: add KYVERNO/KYVERNO_JSON/CHAINSAW/YAMLLINT/YQ/CHECK_JSONSCHEMA;
align KUBECTL 1.32.0 -> 1.31.4. dev.sh build(): pass the new --build-args.
- ci/versions.env: pin CHECK_JSONSCHEMA_VERSION=0.29.4 (single source of truth).
Fix harness seams so the gate can run:
- dev.sh run(): mount the sibling spec repo (home-network-k3s) read-only at
/home-network-k3s for ci/spec-immutability.sh (R-TEST-06); conditional so the
container still runs without it.
- conformance/lib/common.sh conf_dirs_for_layer(): accept a comma/space list of
layers and union the dirs, so ci/conformance.sh `run.sh --layer l2,l3` selects
both layers in one pass (backward compatible for single layers).
NOTE: ci/versions.env pins KYVERNO_JSON_VERSION=0.0.5, which has no upstream release
(max tag is v0.0.3; platform/kyverno/scripts/run-l0.sh already defaults to v0.0.3).
Baked v0.0.3 and flagged for PM to correct ci/versions.env. kyverno-json is not used
by any static gate stage.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- drop stale libvirt socket mount + LIBVIRT_DEFAULT_URI (old KVM cluster)
- auto-open an SSH tunnel (local :16443 -> primary 127.0.0.1:6443 over the
pfSense SSH NAT) on each run so kubectl/flux/helm reach the cluster with no
manual setup; DEV_NO_TUNNEL=1 opts out, K3S_SSH_* env overrides the NAT front
- refresh header/usage comments (bpg/proxmox, not dmacvicar/libvirt)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- inventory routes SSH via 192.168.2.102:2250/2251/2252; new k3s_node_ip carries
each node's real 192.168.1.x for --node-ip / k3s_api_host / Cilium parity
- dev.sh bind-mounts ~/.config/home-k3s/ansible-vault-pass (ro) so vaulted
playbooks can run in the pinned container (ansible.cfg vault_password_file)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- ansible hosts.ini: node ansible_host -> 192.168.1.50/.51/.52, with a
clear note that the PM may need to swap these for the pfSense
NAT-forwarded address:port once that NAT rule exists (not guessed here).
k3s_api_host derives from the inventory, so it follows automatically.
- Cilium CiliumLoadBalancerIPPool -> 192.168.1.53-.60 (matches the tofu
cilium_l2_vip reservation on the new LAN).
- CiliumL2AnnouncementPolicy: broaden interface selector to ^e(n|th) so
the Proxmox NIC name (ens18/eth0) need not be pinned; flagged
verify-at-bringup.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
bpg/proxmox generates cloud-init network-config (static IP/gw/DNS) and
meta-data natively from the VM initialization block, so remove
network-config.yaml.tftpl and meta-data.yaml.tftpl. The rich first-boot
baseline (users, SSH key, packages, kernel modules, Longhorn /dev/vdb
mount, swap-off) is preserved and shipped as a user-data snippet
referenced by initialization.user_data_file_id. Comments updated
(libvirt -> Proxmox); /dev/vdb still valid (data disk is virtio1).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the dmacvicar/libvirt provider + resources with the modern
bpg/proxmox provider. The dev cluster moves off the memory-constrained
local libvirt host onto the Proxmox VE box.
- versions.tf: pin bpg/proxmox ~> 0.66; provider reads auth from env
(PROXMOX_VE_*, token never a tofu var) and normalizes a schemeless
PROXMOX_VE_ENDPOINT (prepend https://) to avoid the :8006 HTTP 301.
- variables.tf: Proxmox placement vars (node=proxmox, datastore=Storage,
image_datastore=local, bridge=vmbr0); LAN 192.168.1.0/24 gw .1 DNS .18;
node_ips .50/.51/.52; sizing 6 vCPU / 16384 MiB / 40+100 GiB; cpu_type
host; Cilium VIP range .53-.60.
- main.tf: proxmox_virtual_environment_download_file (cloud image -> local),
per-node user-data snippet (proxmox_virtual_environment_file), and
proxmox_virtual_environment_vm per node via name-keyed for_each (imported
OS disk virtio0, blank data disk virtio1, native initialization ip_config
+ dns + user_data_file_id).
- remove moved.tf (libvirt-specific count->for_each state migration).
Authoring only; no apply. Traces R-ENV-01, R-PLAT-01.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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.