Commit Graph
100 Commits
Author SHA1 Message Date
jakman85andClaude Opus 4.8 72d4720d58 feat(auth): gate pihole UI with per-app oauth2-proxy sidecar (canary)
Close the un-gated-UI security finding for forward-auth apps (R-IDP-02) by
rendering the Option-B per-app oauth2-proxy reverse-proxy sidecar that crafty
proves working. catalog/render.py now emits, for any forward-auth app with an
exposed UI: the oauth2-proxy sidecar (upstream http://127.0.0.1:<uiport>,
:4180, --allowed-group per auth.group), an authproxy :4180 Service port, an
HTTPRoute backendRef -> :4180, and a gateway-ingress CiliumNetworkPolicy that
allows the Gateway to reach ONLY :4180 (fail-closed: the bare UI is never
served un-authenticated). oidc/native/none apps render byte-identical.

This is the CANARY: only pihole's golden carries the sidecar and is verified
fail-closed in-cluster first. jackett/qbittorrent/radarr/sonarr are deferred to
the rollout commit (temporarily listed in ci/render-golden.sh) so their UIs
keep serving exactly as today until their clients+secrets land.

- keycloak: confidential pihole client (realm-import + realm-clients mirror),
  provisioned live via kcadm (groups membership mapper, bare names).
- secret: SOPS/age pihole-oauth2-proxy-credentials (client-id/secret/cookie),
  wired into apps/wave-0 via the renderer's new sidecar-creds wave rule.
- L1 render goldens regenerated for the sidecar (forward-auth fixtures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 08:36:03 -05:00
jakman85andClaude Opus 4.8 a04251bbc8 docs: plan for Longhorn SSD tiering (hot-tier move off HDD)
Investigation + phased implementation plan to move the churn-heavy,
latency-sensitive Longhorn volumes (Prometheus, the CNPG Postgres DBs,
Loki, gitea) off the slow/failing HDD 'Storage' pool onto a tagged
SSD-backed Longhorn disk on local-zfs, ending the reboot rebuild storm.

Key finding: the recorded 'SSD does not fit Longhorn' blocker was based
on the 384 GiB provisioned figure; actual working set is ~79 GB
cluster-wide (~26 GB/node) since all bulk media is on NFS, not Longhorn.
Hot tier is ~51 GB actual / 114 GiB provisioned and fits the ~323 GiB
SSD AVAIL with headroom. Read-only investigation; no live storage
touched.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 08:27:21 -05:00
jakman85andClaude Opus 4.8 d60e5531b1 test(conformance): correct stale ImageVolume-gate rationale for k3s v1.36.4
The three KubeVirt VM conformance tests (vm-lifecycle, vm-default-deny,
vm-data-survival) carried comments asserting the ALPHA `ImageVolume` feature
gate is DISABLED "on this k3s v1.31 cluster" and that the apiserver therefore
prunes the container-disk `image` volume, breaking the containerDisk boot path.

The cluster was upgraded v1.31 -> v1.36.4. Revalidated the gate live:
  - kubernetes_feature_enabled{name="ImageVolume"} = 1 (enabled by default;
    stage="" — no longer ALPHA)
  - a server-side `kubectl apply --dry-run=server` of a Pod with an `image`
    volume RETAINS the volume + mount instead of pruning them.

So the old premise is stale: containerDisk roots would boot on 1.36.4. The
tests never asserted the gate state and never used containerDisk — they boot
from CDI DataVolume roots, which remains the correct choice because it is the
spec-required, rebuild-surviving disk model (docs/06, R-STATE-01); an ephemeral
containerDisk could never satisfy data-survival. Test logic is therefore
unchanged; only the explanatory comments are corrected (v1.31 -> v1.36.4, and
the "containerDisk is broken here" rationale marked stale with the live
evidence).

Merge gate: PASS (lint, l0, render-golden, l1 green).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 01:19:46 -05:00
jakman85andClaude Opus 4.8 b745bab26b docs(plan): evaluate Cilium 1.20.1 native Gateway ExternalAuth vs oauth2-proxy
Research + implementation plan (no live change) on whether Cilium 1.20.1's
native Gateway API ExternalAuth filter (GEP-1494) can replace the un-wired
forward-auth CEC template and Crafty's oauth2-proxy sidecar.

Verdict: DEFER. The filter exists in 1.20.1 code (shipped 1.20.0, PR #45739) but
is EXPERIMENTAL-channel Gateway API; the cluster runs the standard-channel CRDs
(verified: HTTPRoute filters[].type enum lacks ExternalAuth), so it is not even
installable today. Also: fail-open bug (#47178, fixed #47265) in a brand-new
security filter, no 1.20 docs, and the ext_authz hop would hit the same
enforce_policy_on_l7lb hairpin re-evaluation just fixed for Keycloak (68690e5).

Plan is phased/reversible (pihole canary first) and keeps the working sidecar.
Resolves the DECISIONS-LOG open item on ext_authz workaround relevance under 1.20.1.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 01:19:36 -05:00
jakman85andClaude Opus 4.8 676c37a247 tofu: node vCPU 6 -> 8 (CPU spike headroom)
Steady-state CPU PSI 15-23% + 15-min load 5.6-8.0 on 6 cores (run queue at/over core count) left no
headroom for bursty workloads. Proxmox host has 24 cores at ~4% load, so 3x8 = 24 vCPU is 1:1 with
zero oversubscription. In-place tofu update (cores change is pending until a per-node VM stop/start);
rolled one node at a time to keep etcd quorum. Comment also corrected 16->24 GiB (stale).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 23:47:49 -05:00
jakman85andClaude Opus 4.8 8a424bcba3 test(l1): regenerate render goldens for the keycloak-egress renderer change
The container-workload and egress-class L1 fixtures use auth.mode=forward-auth, so the renderer now
emits the keycloak backend egress allow for them too. Regenerate the committed goldens to match the
canonical render. MERGE GATE: PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 23:35:20 -05:00
jakman85andClaude Opus 4.8 68690e5019 fix(net): allow SSO apps egress to keycloak backend (Cilium 1.20 CVE-2025-30162)
App SSO broke cluster-wide after the Cilium 1.16.5->1.20.1 upgrade: the CVE-2025-30162 fix (in
>=1.16.8/1.17.2) makes the shared Gateway's L7 proxy re-evaluate a pod's egress policy against
Keycloak's REAL backend cluster identity (enforce_policy_on_l7lb) when it hairpins to keycloak.home.
'toEntities: world' matches the Gateway VIP at L3/L4 but not the resolved keycloak identity at L7, so
OIDC discovery/token 403'd ('Access denied') for every app with a NetworkPolicy (gitea/nextcloud/
semaphore/headlamp/crafty...); Grafana survived only because monitoring has no NetworkPolicy.

Fix: explicit toEndpoints egress allow to the keycloak backend for SSO apps.
- catalog/render.py: emit it for auth.mode in {oidc,forward-auth}; re-rendered 8 app goldens.
- platform/headlamp + platform/crafty: hand-written policies get the same allow.
Split-horizon design unchanged. Verified live: headlamp-ns -> keycloak discovery 403 -> 200.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 23:34:03 -05:00
jakman85andClaude Opus 4.8 45deac9d50 keycloak: fix conditional-OTP role config key (blocked all admin SSO with 403)
The browser-mfa conditional-user-role authenticator used config key 'condition.evaluated.user.role',
which is not a real Keycloak property — the role resolved to null, so Keycloak logged 'Invalid role
name submitted: null' and the REQUIRED conditional-OTP step threw, 403-ing every platform-admins login
(Headlamp, Grafana admin, etc.). Correct key for Keycloak 26 is condUserRole=platform-admin.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 21:56:13 -05:00
jakman85andClaude Opus 4.8 a720c73fcd monitoring: node CPU-pressure alerts + PSI/load dashboard (#4)
Adds NodeCPUPressureHigh (CPU PSI 'some' > 15% for 15m) and NodeCPULoadHigh (load5/core > 1 for 15m)
to platform-health.nodes, plus a 'Nodes — CPU / Memory Pressure' Grafana dashboard (PSI + load-per-core
+ utilization). These surface the bursty CPU contention that averaged CPU%/kubectl top hides — the
signal behind 'spikes cause blips of delay'. Makes the 6->8 vCPU resize measurable.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 21:51:15 -05:00
jakman85andClaude Opus 4.8 3e61f817b6 kps: raise Prometheus (2->3Gi) + Grafana (512->768Mi) memory limits
Both were pinned at ~98% of their limits (Prometheus 2002/2048Mi, Grafana 499/512Mi) with no
headroom for TSDB compaction / dashboard-render spikes — the classic OOM setups. Nodes are 24Gi at
38-44% used (~14Gi free each; the '~22 GiB ceiling' comment predates the 16->24Gi node bump), so the
extra headroom is comfortable. Grafana request 256->384Mi, Prometheus request unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 21:33:40 -05:00
jakman85andClaude Opus 4.8 1f921fbfed docs(ansible): add re-provision runbook + node-host playbook list
A node rebuild (tofu -replace / fresh OS) wipes host-level config Flux/Helm don't manage.
site.yml omits kube-vip.yml and vpn.egress.gateway.yml, so a re-provision missed the node-host
OpenVPN on k3s-dev-3 and dropped all egress-class=vpn pod traffic. Document the node-host layers
that must be re-run after any re-provision, and correct the stale author-only banner (these plays
are live).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 21:18:29 -05:00
jakman85andClaude Opus 4.8 22db5fdb63 kyverno: 3.3/v1.13 -> 3.9/v1.19 (stay-current) + CLI lockstep
- HR chart 3.3.x -> 3.9.x (Kyverno v1.13.6 -> v1.19.0).
- crds.install:false + CRDs pre-applied server-side (5.7 MB bundle exceeds kubectl's 256 KB
  annotation limit; Helm client-side apply would stall the platform Kustomization, kps-style).
- CLI lockstep (ci/conformance/devcontainer versions.env + run-l0.sh default) -> 1.19.0.
  Pre-verified: v1.19.0 kyverno CLI passes the L0 suite 17/17 with the existing test format.
- Policies are kyverno.io/v1 ClusterPolicy (stable API, unchanged in v1.19).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 16:28:16 -05:00
jakman85andClaude Opus 4.8 954b9616b9 loki: chart 6.x -> 7.x (Loki 3.6.x; stay-current)
chart 7 major bump. Verified chart 7.3.0 renders identically for this SingleBinary/filesystem/
Longhorn config (one loki StatefulSet, no extra read/write/backend/cache pods, no schema errors).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 16:21:32 -05:00
jakman85andClaude Opus 4.8 cbc2301b30 docs: reconcile docs/comments with post-upgrade cluster state
Text-only cleanup after the upgrade campaign. Updates stale version
strings, prose, and comments to the verified current facts; no
Kubernetes behavior or version pins changed except the kubectl
toolchain bump (below).

Verified facts applied: k3s v1.36.4+k3s1, Debian 13 trixie,
Cilium 1.20.1 / Gateway API v1.6.1, Longhorn 1.12.1, node RAM 24 GiB,
dedicated virtio2 SSD etcd disk per node, Velero chart 12.x / v1.18,
Grafana 13.2.0.

- Version strings across infra/ and platform/ READMEs + comments
- kubectl toolchain 1.31.4 -> 1.36.4 (devcontainer/ci/conformance
  versions.env; 1.31 vs a 1.36 cluster exceeded the +-1 skew)
- Retired-single-apps-Kustomization refs -> apps-wave-0/1/2 + apps-vms
  with per-wave SOPS decryption (flux/clusters docs + bootstrap.sh)
- COMPLETED/historical banners on the three executed plan docs
- Resolved TODO(WS-SEC) markers -> Renovate-maintained ranges
- Appended dated DECISIONS-LOG entry (2026-08-30) superseding the
  2026-08-26 LIVE STATE line

Left as revalidation items (not blind-edited): external-snapshotter
v8.2.0 vs 1.36, Cilium ext_authz/forward-auth relevance under 1.20.1,
conformance ImageVolume feature-gate assertions under 1.36.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 15:13:12 -05:00
jakman85andClaude Opus 4.8 dbd73f1f88 kube-vip: v0.8.9 -> v1.2.3 (k8s 1.36 catch-up)
Manifest env-var config (manager/cp_enable/svc_enable/vip_arp/leaderelection) is unchanged across
0.8->1.2, so the bump is image + re-applied auto-deploy manifest.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 14:58:33 -05:00
jakman85andClaude Opus 4.8 b2b1aa55e2 kps: bump Grafana memory limit 256Mi->512Mi (Grafana 12.x OOMKill was root cause of 65->88 upgrade timeout)
Grafana 12.x bundled in kps 88 OOMKilled (exit 137) at 256Mi during grafana-apiserver/unified-storage
startup — no fatal log, just SIGKILL. The new Grafana pod never became Ready, so Helm --wait ran the full
20m and rolled back to 65.8.1 every time. 512Mi holds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 14:52:06 -05:00
jakman85andClaude Opus 4.8 00d895d25f kps: 20m timeout + no-rollback remediation for the 65->88 catch-up
The 65->88 multi-major upgrade exceeded the default 5m Helm timeout (full operator/KSM/
node-exporter + Prometheus/Alertmanager StatefulSet rollout), so Flux rolled back to 65.8.1
four times and Stalled, blocking the platform kustomization and everything downstream.
CRDs pre-applied server-side at operator v0.93.1.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 12:08:29 -05:00
jakman85andClaude Opus 4.8 bbe08e6762 velero: chart 8.x->12.x (Velero 1.15->1.18), plugin-for-aws v1.14.2 (k8s 1.36 catch-up)
CRDs pre-applied server-side (upgradeCRDs:false; chart hook image was removed from Docker Hub).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:55:29 -05:00
jakman85andClaude Opus 4.8 10b5228105 cert-manager sequential upgrade -> 1.21 (k8s 1.36 catch-up, final hop)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:52:42 -05:00
jakman85andClaude Opus 4.8 c868382f5a cert-manager sequential upgrade -> 1.20 (k8s 1.36 catch-up)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:48:26 -05:00
jakman85andClaude Opus 4.8 e67eee527a cert-manager sequential upgrade -> 1.19 (k8s 1.36 catch-up)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:42:40 -05:00
jakman85andClaude Opus 4.8 e42c4a32f1 cert-manager sequential upgrade -> 1.18 (k8s 1.36 catch-up)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:35:18 -05:00
jakman85andClaude Opus 4.8 7d2f92b25e cert-manager -> 1.17 (k8s 1.36 catch-up, hop 1/5)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:26:05 -05:00
jakman85andClaude Opus 4.8 037aa7fe9a Component catch-up: kube-prometheus-stack 65->88 (po v0.93, k8s 1.36); CRDs pre-applied
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:23:13 -05:00
jakman85andClaude Opus 4.8 19a368b237 Component catch-up batch 1: external-dns 1.21, oauth2-proxy 10.x, Alloy 1.x, CNPG in-place flag
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:21:55 -05:00
jakman85andClaude Opus 4.8 ba72725a4e OS upgrade complete: Debian 12->13 (trixie) baseline + re-provision role fixes
All 3 k3s nodes re-provisioned to Debian 13 (kernel 6.12) via immutable tofu -replace,
one at a time, primary/leader last (secondary-join to avoid cluster-init split-brain).
- variables.tf image defaults -> debian-13-generic (generic, not genericcloud)
- k3s_server: ensure /etc/rancher/k3s exists (fresh-node re-provision fix)
- gitignore vendored ansible collections (installed for re-provision)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:12:53 -05:00
jakman85andClaude Opus 4.8 d84feac736 OS upgrade complete: Debian 12->13 (trixie) baseline + re-provision role fixes
All 3 k3s nodes re-provisioned to Debian 13 (kernel 6.12) via immutable tofu -replace,
one at a time, leader/primary last. variables.tf image defaults -> trixie generic.
k3s_server role: ensure /etc/rancher/k3s exists (fresh-node re-provision fix).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 11:09:57 -05:00
jakman85andClaude Opus 4.8 ef0a735145 node_baseline: add cryptsetup (Longhorn 1.12 RequiredPackages) for Debian 13 re-provision
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 10:12:53 -05:00
jakman85andClaude Opus 4.8 7a59ef07bb Upgrade complete: pin k3s_version v1.31.5 -> v1.36.4+k3s1
Cluster upgraded 1.31.5 -> 1.36.4 over 5 sequential minor hops (binary-swap per node,
leader-last, Longhorn rebuild-freeze). Components: Cilium 1.16.5->1.20.1, Longhorn
1.7.3->1.12.1, GW-API CRDs ->v1.6.1, etcd 3.5->3.6. All validated; data intact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 02:17:17 -05:00
jakman85andClaude Opus 4.8 6bc4406dca W5 hardening: Longhorn HR timeout 15m + no downgrade-rollback
Root cause of a W5 wedge: default 5m Helm timeout on slow-HDD Longhorn upgrades caused
a rollback attempt after the data had already migrated to the new minor, which the old
manager fatally refuses. Give the upgrade room and never auto-rollback (fail forward).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 01:04:15 -05:00
jakman85andClaude Opus 4.8 b4b3bb0922 W5 (FINAL): Cilium 1.19.7->1.20.1 (+GW-API CRDs v1.6.1), Longhorn 1.11->1.12.1 (pre-k8s-1.36)
Cilium 1.20 is mandatory for k8s 1.36. GW-API CRDs v1.4.1->v1.6.1 (direct, never v1.5.x).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 00:22:53 -05:00
jakman85andClaude Opus 4.8 39cc09e6b3 W4: Longhorn 1.10->1.11.3 (pre-k8s-1.35; Cilium/GW-API CRDs held steady)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 00:13:11 -05:00
jakman85andClaude Opus 4.8 36173b9529 W3: Cilium 1.18.13->1.19.7 (+GW-API CRDs v1.4.1), Longhorn 1.9->1.10.2 (pre-k8s-1.34)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 00:00:23 -05:00
jakman85andClaude Opus 4.8 cd8cf6220b W2: Cilium 1.17.18->1.18.13 (+GW-API CRDs v1.3.0), Longhorn 1.8->1.9 (pre-k8s-1.33)
Gateway API CRDs bumped v1.2.1->v1.3.0 (required by Cilium 1.18; applied server-side).
Land on current k8s 1.32 before the 1.33 hop.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 23:46:09 -05:00
jakman85andClaude Opus 4.8 5efd983239 W1: Cilium 1.16.5->1.17.18, Longhorn 1.7->1.8 (pre-k8s-1.32 component landing)
Land the two pacing components on current k8s 1.31 before the 1.32 k3s hop.
Cilium 1.17 supports k8s 1.29-1.32; Longhorn 1.8 supports 1.25+; Gateway API CRDs
stay v1.2.1 (Cilium 1.17 targets v1.2.x). Also commits the full upgrade plan doc.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 23:23:34 -05:00
jakman85andClaude Opus 4.8 dd8238a017 Migrate Flux image-automation API v1beta2 -> v1 (pre-Flux-2.7 upgrade)
Prereq for the Flux v2.7+ upgrade (W4). Flux 2.4 already serves image.toolkit/v1,
so this is safe now. render.py source + platform/image-automation + L3 test + L1 goldens.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 23:22:35 -05:00
jakman85andClaude Opus 4.8 d24a443c7d chore(flux): migrate image-automation API v1beta2 -> v1
Migrate image.toolkit.fluxcd.io/v1beta2 -> /v1 for ImageRepository,
ImagePolicy, and ImageUpdateAutomation ahead of the Flux v2.7+ upgrade
(cluster-upgrade-plan §3.2). Flux v2.4 (current) already serves /v1, so
migrating the manifests now is a safe no-op at runtime.

- catalog/render.py: r_image_automation emits v1 for ImageRepository/ImagePolicy
- platform/image-automation/{image-update-automation,per-service/pihole.example}
- conformance L3 hands-off-update chainsaw applies v1 CRs
- conformance L1 goldens re-rendered to match (apiVersion-only diff)

No spec-field changes required: all spec fields used (image, interval,
imageRepositoryRef, filterTags.pattern/extract, policy.semver.range,
sourceRef, git.*, update.strategy/path) are unchanged in v1; the v1
messageTemplate already uses .Changed (not the removed .Updated).
apps/ golden unaffected (canonical render uses --no-image-automation).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 23:19:20 -05:00
jakman85andClaude Opus 4.8 8dfb42f6c0 docs: add Phase 2 SSD-etcd execution runbook
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 21:28:46 -05:00
jakman85andClaude Opus 4.8 e1556e3004 Merge SSD-backed etcd isolation: virtio2 disk per node + node_baseline mount
Dedicated SSD (rpool/local-zfs) virtio2 disk per k3s VM, etcd migrated to
/var/lib/rancher/k3s/server/db on /dev/vdc, isolating etcd WAL fsync from the
HDD Longhorn churn. Applied live one node at a time (dev-2/dev-1/dev-3, leader
last) with rebuild-freeze; dev-3 also bumped 16->24GB. All 3 nodes verified:
etcd on SSD, 3/3 healthy, 18/18 volumes healthy, tofu plan clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 21:28:41 -05:00
jakman85andClaude Opus 4.8 af5fc5b25f feat(tofu,ansible): SSD-backed etcd disk per k3s node (Phase 1: code + plan)
Interim bridge to stop Longhorn replica-rebuild I/O storms on the shared HDD
Storage pool from starving etcd WAL fsync (crash-looping k3s / flapping the
kube-vip VIP). Adds a small dedicated SSD disk per node for etcd only; Longhorn
stays on HDD. See docs/plans/ssd-etcd-isolation.md.

tofu (infra/tofu):
- New vars etcd_ssd_datastore (default "local-zfs" = rpool/data, Micron 5100
  SATA SSD mirror w/ PLP) and etcd_disk_gib (default 16).
- New virtio2 disk block on the SSD datastore per VM: discard=on, cache=none
  (honor guest fsync straight to the ZFS ZIL — safe on PLP SSD), iothread=false.
  Kept last (ascending interface) so bpg does not reorder virtio0/virtio1.
- Revert the debian_cloud_image resource to the long type name
  proxmox_virtual_environment_download_file to match the R2 state: bpg >= 0.111
  dropped the cross-type moved{} linkage to the short proxmox_download_file, so
  the stale moved block failed EVERY plan with "Resource type mismatch" (broken
  on main too). Aligning code to state yields a clean plan with no image
  re-download. Short-name migration is deferred to a deliberate `tofu state mv`.

ansible (node_baseline):
- Format /dev/vdc (label k3s-etcd) and mount it at
  /var/lib/rancher/k3s/server/db (defaults,noatime; no nofail so a missing etcd
  disk fails loudly rather than silently landing etcd on the root HDD) before
  k3s starts, so a rebuilt node comes up with etcd isolated on SSD. Data-safe:
  filesystem module never reformats an existing fs.

Phase 1 only: non-disruptive. No tofu apply, no node/VM/etcd/Longhorn changes.
tofu plan verified: 0 to add, 3 to change, 0 to destroy — every VM an in-place
disk ADD (virtio2), dev-3 also an in-place memory 16384->24576; no VM replace.
Merge gate green (render==golden included).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 17:21:06 -05:00
jakman85andClaude Opus 4.8 4c334d3cc9 Merge: Headlamp OIDC secret keys renamed to OIDC_* (fix empty-scheme login)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 17:12:18 -05:00
jakman85andClaude Opus 4.8 be3b4f7e8e Merge: Longhorn concurrent-replica-rebuild-per-node-limit=1 (HDD etcd protection)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 17:12:12 -05:00
jakman85andClaude Opus 4.8 071123ae43 fix(headlamp): rename OIDC secret keys to OIDC_* so chart args expand
Headlamp OIDC login failed with `unsupported protocol scheme ""`. The
kubernetes-sigs/headlamp chart `envFrom`s the headlamp-oidc Secret verbatim
and renders args like `-oidc-idp-issuer-url=$(OIDC_ISSUER_URL)`, so the Secret
keys must be the exact env var names. The keys were camelCase
(clientID/issuerURL/scopes/callbackURL/clientSecret), following the mistaken
`.example`, so `OIDC_ISSUER_URL` was never defined. Kubernetes left the literal
`$(OIDC_ISSUER_URL)` in the arg and Headlamp's OIDC discovery hit
`Get "$(OIDC_ISSUER_URL)/.well-known/openid-configuration": unsupported
protocol scheme ""`.

Rename the SOPS Secret keys to OIDC_CLIENT_ID / OIDC_CLIENT_SECRET /
OIDC_ISSUER_URL / OIDC_SCOPES / OIDC_CALLBACK_URL (values unchanged, still
SOPS/age-encrypted). Fix the `.example` and README so the correct, load-bearing
key names are documented and the camelCase footgun can't recur.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 15:09:15 -05:00
jakman85andClaude Opus 4.8 5c64e06354 fix(longhorn): cap concurrent-replica-rebuild-per-node-limit=1 (HDD etcd protection)
The k3s VM disks are HDD-backed, so a full Longhorn replica rebuild's
sequential I/O can saturate the spindle and starve etcd's WAL fsync,
crash-looping etcd and flapping the kube-vip VIP (observed during a
rolling node resize). Cap rebuilds to one replica per node at a time so a
node bounce or replica replenishment can never storm the disks.

Set via HelmRelease defaultSettings.concurrentReplicaRebuildPerNodeLimit
(GitOps-managed, permanent). replica-replenishment-wait-interval stays at
the Longhorn default of 600s. Reference values.yaml kept in sync.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 15:03:03 -05:00
jakman85 7910128675 Merge: tofu memory_mib 16384→24576 (k3s node RAM bump target) 2026-08-29 14:58:15 -05:00
jakman85andClaude Opus 4.8 a60d34a4c3 tofu: bump k3s node memory_mib default 16384 -> 24576 (24 GiB)
Raise per-node RAM from 16 GiB to 24 GiB for proper failover headroom.
Matches the live rolling resize of the three k3s-dev VMs on Proxmox
(qm set --memory 24576 per node, one at a time, KSM enabled first).

Single memory_mib var applies to all three nodes via for_each, so a
future `tofu apply` would resize all three at once and break etcd
quorum — the resize MUST be done live/rolling, not via apply.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 12:55:45 -05:00
jakman85andClaude Opus 4.8 178d130dc3 fix(velero): disable CRD-upgrade hook instead of pinning bitnami/kubectl
Supersedes the previous kubectl-image repoint. The velero chart's pre-install
`velero-upgrade-crds` HOOK job runs docker.io/bitnami/kubectl, whose public
Docker Hub catalog Bitnami removed (Aug 2025); `bitnami/kubectl:1.31` 404s,
leaving the hook in Init:ImagePullBackOff so Velero never installs.

Repointing the hook to the exact-version rancher/kubectl:v1.31.5 (prior commit)
does not work: that image is distroless, and the hook's init runs
`/bin/sh -c "cp $(which sh)/... $(which kubectl) /tmp"` — with no /bin/sh it
crashes StartError (verified on-cluster).

The hook exists ONLY to UPDATE CRDs on chart upgrades — it is NOT how CRDs are
first installed. The chart ships all 13 Velero CRDs in its native `crds/`
directory, which Helm applies unconditionally on install (before hooks),
independent of this flag; the CRDs are already present on-cluster from Helm's
crds/ handling. Setting `upgradeCRDs: false` drops the fragile external
kubectl-image dependency entirely while CRDs still install. Trade-off: Helm
won't manage crds/ after install, so CRD schema upgrades become a
manual/Renovate step; acceptable given Velero's CRDs are stable.

All other Velero values (BSL -> versitygw s3://velero, kopia FSB node-agent, the
two Schedules, the velero-cloud-credentials secret) are unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 11:09:45 -05:00
jakman85andClaude Opus 4.8 8fdd2a3f60 fix(velero): repoint CRD-hook kubectl image off the removed bitnami mirror
The velero chart's pre-install `velero-upgrade-crds` hook defaults its kubectl
image to docker.io/bitnami/kubectl, whose public Docker Hub catalog Bitnami
removed (Aug 2025). `docker.io/bitnami/kubectl:1.31` now 404s, leaving the hook
job in Init:ImagePullBackOff so Velero never installs (no Deployment,
node-agent, or BackupStorageLocation).

Set kubectl.image to the Rancher-maintained mirror docker.io/rancher/kubectl,
pinned to the cluster's exact k8s minor v1.31.5 (multi-arch amd64/arm64,
verified pullable via the Docker Hub registry API). All other Velero values
(BSL → versitygw s3://velero, kopia FSB node-agent, the two Schedules, the
velero-cloud-credentials secret) are unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-29 11:01:17 -05:00
jakman85 b5d65e9bc8 Merge: Velero (kopia FSB) + CNPG Barman backups → S3 target (resolves OQ-BKP) 2026-08-28 22:50:23 -05:00
jakman85andClaude Opus 4.8 6739339f53 Replace MinIO backup target with versitygw (s3_backup_target role)
MinIO's open-source edition is de-prioritised (console removed, commercial
pivot), so swap the off-cluster S3 backup target on the Proxmox host to
versitygw v1.7.0 (github.com/versity/versitygw) — a maintained Go S3 gateway
serving a POSIX filesystem as S3.

Rename role minio_backup_target -> s3_backup_target and playbook backup-minio
-> backup-s3. The role now:
  - decommissions any pre-existing MinIO (service/binary/env/mc-client/user/
    object-layout), KEEPING the ZFS dataset Storage/k3s-backups and its mount
  - installs the pinned, checksummed versitygw binary (fetched+extracted on the
    controller, host has no internet), a dedicated versitygw system user, and a
    Type=simple hardened systemd unit running the posix backend rooted at
    /mnt/Storage/k3s-backups/data, bound to 192.168.1.9:9000 (same endpoint),
    plain HTTP / path-style
  - ensures the dataset has xattr=sa (versitygw stores object metadata in xattrs)
  - pre-creates the velero + cnpg buckets (bucket == top-level subdir)

Credentials are REUSED: versitygw's ROOT key is set to the same scoped
access_key/secret the cluster already uses, so endpoint + key + secret + bucket
names are byte-identical to the old MinIO target. The GitOps SOPS secret
(platform/backup/minio-credentials.sops.yaml) and all cluster-side Velero/CNPG
config need ZERO change. The vaulted group_vars now expose
s3_backup_target_access_key/_secret_key (same values, MinIO root creds retired).

Verified live on the Proxmox host: versitygw active+enabled on 192.168.1.9:9000,
MinIO fully removed, dataset intact; SigV4 ListBuckets with the reused key
returns velero+cnpg; a throwaway in-cluster pod reaches http://192.168.1.9:9000
(versitygw S3 response). Re-run is idempotent (no host changes).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 22:48:38 -05:00
jakman85andClaude Opus 4.8 d1248e6778 feat(backup): wire Velero + CNPG Barman to the live MinIO S3 target
Author the GitOps side of off-cluster backups against the on-Proxmox MinIO
(http://192.168.1.9:9000, path-style; buckets velero + cnpg).

Velero (R-STATE-03):
- HelmRelease (chart 8.x / Velero 1.15) + velero-plugin-for-aws init container +
  node-agent kopia File System Backup (deployNodeAgent, uploaderType=kopia,
  snapshotsEnabled=false). BSL default -> bucket velero, s3ForcePathStyle.
- Two daily Schedules: `daily` backs up the whole cluster's resources+PVC data
  (defaultVolumesToFsBackup opt-out) EXCEPT monitoring+unifi; `daily-resources-only`
  captures monitoring+unifi RESOURCES only (Prometheus TSDB, Loki 72h logs, and the
  ~7.4GiB UniFi VM disk are reconstructable — excluded from volume backup).
- velero-cloud-credentials SOPS Secret (cloud INI key, ns velero).
- Own non-gating Flux tier clusters/base/backup.yaml (dependsOn platform-config,
  wait:false) + clusters/dev SOPS decryption patch — mirrors headlamp.

CNPG native Barman PITR (R-IDP-05 / R-STATE-03):
- keycloak-db, nextcloud-db, semaphore-db gain spec.backup.barmanObjectStore
  (s3://cnpg/<cluster>, endpointURL MinIO, gzip WAL+data, 30d retention) + a daily
  ScheduledBackup (6-field cron, immediate) each.
- Per-namespace scoped-key SOPS Secrets (<db>-backup-credentials), same-ns only.
- render.py: emit-waves now recognizes <svc>-db-backup-credentials.sops.yaml as a
  DB-wave sidecar (+ self-test); wave-0 kustomization regenerated.

Creds mirror platform/backup/minio-credentials.sops.yaml (scoped key k3s-backup),
SOPS round-trip verified with the cluster age key. gate: green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 22:37:17 -05:00
jakman85 1bee2554f8 Merge: MinIO S3 backup target on Proxmox (ansible role + live) + SOPS creds 2026-08-28 22:16:25 -05:00
jakman85andClaude Opus 4.8 ac32f01f7b feat(backup): MinIO S3 backup target on Proxmox host for Velero + CNPG
Stand up a single-node/single-drive MinIO S3 endpoint on the Proxmox host as the
off-cluster backup target for Velero (R-STATE-03) and CloudNativePG (R-IDP-05).

Ansible (author + executed):
- roles/minio_backup_target + playbooks/backup-minio.yml — idempotent, additive only:
  ZFS dataset Storage/k3s-backups (zstd, quota=100G, /mnt/Storage/k3s-backups, no
  sharenfs); minio system user; pinned+checksummed MinIO/mc binaries (fetched on the
  controller since the host has no WAN, copied to host); hardened systemd unit bound to
  the host's cluster-reachable LAN IP (auto-discovered) on :9000; velero + cnpg buckets;
  a non-root access key scoped by IAM policy to only those two buckets.
- inventory: new [proxmox_hosts] group ([email protected].2.102); overrides the cluster-wide
  ansible_user. Does not disturb existing k3s node entries.
- group_vars/proxmox_hosts/vault.yml (ansible-vault): root + scoped MinIO creds.

GitOps hand-off:
- platform/backup/minio-credentials.sops.yaml (+ .example) — SOPS/age-encrypted endpoint +
  scoped key/secret for the Velero/CNPG wiring (WS-STORAGE/WS-SEC). Not yet wired into a
  kustomization (left for the consuming agents).

Verified live: dataset props correct; minio active+enabled bound to 192.168.1.9:9000;
S3 endpoint http://192.168.1.9:9000/minio/health/ready returns 200 from an in-cluster pod;
buckets velero+cnpg present; scoped key created+policy attached; re-run is idempotent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 22:15:05 -05:00
jakman85andClaude Opus 4.8 748e9ca55d fix(oauth2-proxy): --allowed-groups → --allowed-group (correct oauth2-proxy v7.8.1 flag)
The plural --allowed-groups does not exist in oauth2-proxy v7.8.1; the shared
oauth2-proxy new pod and the crafty auth sidecar both crashlooped on 'unknown flag'.
The flag is the singular, repeatable --allowed-group.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 15:57:55 -05:00
jakman85 d5773c780d Merge: Crafty Controller (Minecraft panel) behind Keycloak oauth2-proxy reverse-proxy + L4 game-port VIP 2026-08-28 15:40:55 -05:00
jakman85andClaude Opus 4.8 0437871807 Onboard Crafty Controller (Minecraft panel) with Keycloak-gated web UI
Add Crafty Controller 4 (registry.gitlab.com/crafty-controller/crafty-4:4.10.8)
as a platform component: a web panel that runs Java + Bedrock Minecraft servers as
subprocesses in one pod + a 20Gi Longhorn PVC (mounted 5x by subPath).

Auth = Option B (per-app oauth2-proxy reverse-proxy sidecar), since Cilium 1.16.5
cannot do ext_authz on the shared Gateway:
- oauth2-proxy v7.8.1 sidecar (:4180) reverse-proxies to Crafty's self-signed HTTPS
  UI (127.0.0.1:8443), gated to Keycloak homelab realm group platform-admins.
- Confidential `crafty` client provisioned live via kcadm + captured in the realm
  import; client secret + cookie secret via SOPS/age (mirrored in realm-clients).

- Web UI: HTTPRoute crafty.home.keithjackson.dev -> Service :80 -> sidecar :4180.
- Game ports: Cilium L2 LoadBalancer on VIP 192.168.1.55 (next free after .53/.54),
  starter set TCP 25565-25567 (Java) + UDP 19132-19134 (Bedrock), NOT auth-gated.
- fsGroup:0 / runAsGroup:0 / runAsUser:1000 for the image's crafty:root identity on
  the root:root Longhorn PVC (the seerr fsGroup lesson).
- Own non-gating Flux tier clusters/base/crafty.yaml (dependsOn platform-config,
  wait:false) + sops-decryption patch in clusters/dev — a leaf UI must never gate
  the platform.
- Explicit default-deny NetworkPolicy + CiliumNetworkPolicies (platform namespace,
  not app-namespace, so Kyverno does not auto-generate the baseline).

make -C ci gate green; kustomize build of platform/crafty, clusters/base,
clusters/dev clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 15:39:51 -05:00
jakman85 a207103c7b Merge: oauth2-proxy forward-auth hardening (platform-admins floor, skip-button) + ext_authz mechanism research/template 2026-08-28 15:20:38 -05:00
jakman85andClaude Opus 4.8 3e7e9386a0 feat(oauth2-proxy): forward-auth ext_authz enforcement mechanism + group floor
Build the deferred forward-auth ENFORCEMENT for non-OIDC UIs (pihole/*arr/
qbittorrent/homepage; Crafty next), gated by Keycloak via oauth2-proxy.

- helmrelease: add `--allowed-groups=platform-admins` (R-IDP-06 floor:
  authenticated AND in group) + `--skip-provider-button` (302 straight to
  Keycloak; verified the interstitial 403 sign-in page otherwise).
- forward-auth-cec.yaml: interim self-authored CiliumClusterwideEnvoyConfig
  ext_authz listener -> oauth2-proxy /oauth2/auth, failure_mode_allow:false,
  per-route opt-in via virtualHost. TEMPLATE — not Flux-wired (PM deploys with
  an L2 VIP + DNS repoint). The ext_authz stanza is verified in-cluster.
- README: document the mechanism, why per-route ext_authz on the shared Gateway
  is NOT clean on Cilium 1.16.5 (machine-owned cilium-gateway-shared CEC; native
  ExternalAuth/GEP-1494 in no released Cilium), the three options + recommendation,
  the opt-in steps, the in-cluster proof, and the human browser test.

Keycloak `oauth2-proxy` client already carries the callback redirect URI + a
bare `groups` claim (full.path=false) — no realm change needed.

Proof (cilium-envoy v1.30.8): a standalone envoy running this exact ext_authz
stanza against the live oauth2-proxy denied unauthenticated requests (403,
oauth2-proxy sign-in body; upstream never served), while a no-filter control
route on the same backend returned 200. R-IDP-02 property holds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 15:18:47 -05:00
jakman85 3c7d7e0a1b Merge: Prometheus retentionSize cap + dev PVC 5Gi→8Gi (fixes full-disk false alerts) 2026-08-28 14:39:51 -05:00
jakman85andClaude Opus 4.8 eb31d2ef58 fix(monitoring): cap Prometheus TSDB by size to stop false absent()/==0 alerts
The 7 falsely-firing platform alerts (KeycloakDown, KeycloakDatabaseDown,
KubeAPIDown, KubeletDown, VPNTunnelDown, KubeVirtControlPlaneDown,
CDINoDefaultStorageClass) share ONE root cause, not per-rule label mismatches:
the Prometheus PVC is 100% full ("no space left on device" on every WAL commit).

Evidence from the live dev cluster:
- /prometheus is 4.8G/5Gi, 0 bytes free; every scrape logs "Scrape commit
  failed ... write to WAL: no space left on device". Scrapes still return 200
  (targets show healthy, lastScrape=now) but samples are never stored, so the
  TSDB head froze at 2026-08-28 13:45 UTC while the pod clock reads ~19:33 UTC.
- Instant queries at "now" therefore return an EMPTY vector, so every
  absent()/==0 platform rule trips. Evaluated at the last-good-sample time each
  expr is correctly FALSE (keycloak-0/keycloak-db-1 ready=1, apiserver+kubelet
  up=1 x3, tun0 series present, kubevirt control-plane expr empty, CDI default
  present) — the rules are correct; the monitor was simply blind.

`retention` alone prunes only by AGE: this cluster is 3d2h old with 3d dev
retention, so time-pruning has NEVER run and 3d of block churn (~1.2Gi/day) plus
a WAL that ballooned past 1Gi overran the 5Gi disk. Fix adds a size-based
retention cap (new MONITORING_PROMETHEUS_RETENTION_SIZE knob) kept below each
env's PVC so Prometheus drops oldest blocks before the disk fills, and bumps the
dev PVC 5Gi->8Gi for real WAL/head/compaction headroom.

- platform/monitoring/kube-prometheus-stack.yaml: retentionSize: ${knob}
- clusters/base/cluster-vars.yaml: knob default 8GiB (base 10Gi PVC)
- clusters/dev/kustomization.yaml: PVC 5Gi->8Gi, retentionSize 5GiB
- clusters/prod/kustomization.yaml: retentionSize 45GiB (50Gi PVC)
- platform/monitoring/README.md: knob table + rationale

Deploy note (PM): the PVC is already 100% full, so Prometheus cannot self-prune
until it can write. The 5Gi->8Gi bump must land as a Longhorn online expansion
(operator PVC expansion / manual patch); once free space returns, retentionSize
keeps it healthy. Watchdog left untouched.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 14:38:12 -05:00
jakman85 96a5db9ed7 Merge: plex env vars (UID/GID/TZ/ALLOWED_NETWORKS) + Homepage Overseerr→Seerr rename 2026-08-28 13:21:19 -05:00
jakman85andClaude Opus 4.8 cde2cd138f plex: add env vars (PLEX_UID/GID, TZ, ALLOWED_NETWORKS); homepage: rename Overseerr->Seerr
Plex Deployment had no Plex env vars. Add via the ServiceDefinition env list
(rendered verbatim by catalog/render.py):
- PLEX_UID/PLEX_GID=1000 (image chowns /config to this and drops; matches the
  Longhorn ownership convention seerr/semaphore use)
- TZ=Etc/UTC (no other service sets TZ repo-wide)
- ALLOWED_NETWORKS=10.0.0.0/8,192.168.0.0/16 so in-cluster clients (Seerr ->
  plex:32400) are trusted as LAN (pod CIDR 10.0.0.0/16 + svc 10.43.0.0/16 + LAN)

PLEX_CLAIM intentionally not set (4-min TTL is unusable via git; one-time live
claim persists on /config). ADVERTISE_IP deferred: the only candidate host
resolves via split-horizon DNS to the internal Gateway VIP and its remote
reachability is unverified — a wrong value breaks remote access, so omitted
per prefer-safety, documented as a follow-up in catalog/plex.yaml.

Homepage still labeled seerr as "Overseerr"; renamed the tile to "Seerr"
(icon mdi-play-box-multiple keeps — not overseerr-specific).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 13:20:34 -05:00
jakman85 6fddca726b Merge: offline_access scope on kubernetes client (long-lived kubectl OIDC sessions) 2026-08-28 13:04:33 -05:00
jakman85andClaude Opus 4.8 330c99eb8b feat(keycloak): enable offline_access for kubernetes OIDC client
Add the offline_access optional client scope to the `kubernetes`
kubelogin client so the operator's kubectl can request long-lived
OFFLINE refresh tokens (--oidc-extra-scope=offline_access) instead of
being forced to re-auth on the realm's ONLINE session timeouts
(ssoSessionIdleTimeout 30m / ssoSessionMaxLifespan 10h). Offline tokens
honour offlineSessionIdleTimeout 30d with no hard max lifespan.

- keycloak-realm-import.yaml: add optionalClientScopes: [offline_access]
  to the kubernetes client so a fresh realm import reproduces it.
- k8s-oidc-rbac.yaml: document the kubeconfig exec-arg
  (--oidc-extra-scope=offline_access) + clear-cache/re-login steps.

Realm session timeouts unchanged; scope is optional (opt-in) and
client-only. Applied live via kcadm (reversible).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 13:01:37 -05:00
jakman85andClaude Opus 4.8 b6a48203bc fix(homepage): add required proxmox.yaml config to stop CrashLoopBackOff
Homepage v2.1.2 treats proxmox.yaml as a required config file (present in
the image skeleton dir /app/src/skeleton alongside services/settings/
bookmarks/widgets/docker/kubernetes/custom.*). On startup it copies a
default skeleton into /app/config for any required file that is missing.
The ConfigMap provided every skeleton file except proxmox.yaml, so the pod
tried to copyfile the proxmox skeleton into the read-only subPath-mounted
/app/config and crashed with EACCES -> CrashLoopBackOff -> Gateway "no
healthy upstream".

Provide an empty proxmox.yaml in the ConfigMap and mount it via subPath
like the other config files, so Homepage finds all required files and
never writes at runtime. Verified the full required set against the running
image's skeleton dir (9 files); proxmox.yaml was the only missing one.
Bumped checksum/config annotation to re-roll the pod.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 12:59:09 -05:00
jakman85 099c1408b8 Merge: Homepage apex landing dashboard (non-gating tier; forward-auth deferred) 2026-08-28 12:18:40 -05:00
jakman85andClaude Opus 4.8 1749603f87 feat(homepage): apex landing dashboard at home.keithjackson.dev
Deploy Homepage (gethomepage) as the apex landing dashboard aggregating
links to cluster services, in its OWN non-gating post-platform Flux
Kustomization (clusters/base/homepage.yaml, dependsOn platform-config,
wait:false) — mirrors headlamp/oauth2-proxy so a leaf UI can never gate
the platform chain.

- platform/homepage/: plain manifests (ns, config ConfigMap, Deployment,
  Service, default-deny+Cilium ingress NP, apex HTTPRoute). Non-root pod,
  HOMEPAGE_ALLOWED_HOSTS, bundled mdi-* icons (DNS-only egress).
- gateway.yaml: add https-apex + http-apex-redirect listeners for the bare
  apex (the *.home wildcard listener does NOT match it). Apex is already a
  SAN on the wildcard cert — no new Certificate.
- Forward-auth annotated (auth-mode=forward-auth) but NOT enforced: the
  Gateway ext-authz mechanism is an unbuilt Phase-6 WS-NET TODO (same status
  as all other forward-auth apps). Flagged as PM follow-up.

Gate: lint/l0/render-golden(10/10)/l1 green; conformance skipped (no cluster).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 12:17:25 -05:00
jakman85andClaude Opus 4.8 ebe0371b1b platform: move Headlamp out of the gating platform tier + fix -session-ttl crashloop
Headlamp's HelmRelease sat in the wait:true `platform` Flux tier. Its pod
crashlooped (`flag provided but not defined: -session-ttl` — chart 0.40.1
renders a container arg the appVersion v0.40.1 backend lacks; upstream
kubernetes-sigs/headlamp#4883), and because it gated `platform`, the whole
chain stalled: platform Ready=Unknown -> platform-config, oauth2-proxy,
apps-wave-0/1/2 and apps-vms all Ready=False "dependency not ready".

Part A (unblock + durable lesson): a leaf UI must never gate the chain. Move
all Headlamp resources into a new non-gating Flux Kustomization
clusters/base/headlamp.yaml (dependsOn platform-config, wait:false, SOPS
decryption patched per-env in clusters/dev) — mirroring oauth2-proxy exactly.
The new Kustomization owns the whole platform/headlamp component; its HTTPRoute
moves from platform-config into platform/headlamp/kustomization.yaml. Removed
headlamp from platform/kustomization.yaml and its httproute from
platform-config/kustomization.yaml. Now a crashlooping Headlamp can never stall
platform/platform-config/apps.

Part B (make it start): pin the chart to 0.41.0 (`>=0.41.0 <0.42.0`). The
backend added `f.Int("session-ttl", …)` in v0.41.0 and chart 0.41.0 pins that
same image (appVersion == chart version), so chart+image realign; every arg the
chart renders for our values is supported. OIDC config + per-user RBAC
(clusterRoleBinding.create:false) unchanged.

Gate: `make -C ci gate` PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 12:01:01 -05:00
jakman85 d8883e4481 Merge: Headlamp k8s dashboard with Keycloak OIDC + per-user RBAC (audience-mapper) 2026-08-28 11:46:15 -05:00
jakman85andClaude Opus 4.8 5fb097d019 platform: deploy Headlamp dashboard with Keycloak OIDC + per-user RBAC
Add Headlamp (kubernetes-sigs/headlamp Helm chart) at
headlamp.home.keithjackson.dev with native Keycloak OIDC login and PER-USER
RBAC: Headlamp forwards each user's id_token to the k3s apiserver, so the
existing oidc:* group bindings (platform-admins->cluster-admin, viewers->view)
apply per user. The ServiceAccount is deliberately NOT bound to cluster-admin
(clusterRoleBinding.create=false), since user requests ride the forwarded token.

Key integration nuance: the apiserver is configured oidc-client-id=kubernetes,
so it only accepts tokens whose aud contains `kubernetes`. The new `headlamp`
Keycloak client carries an oidc-audience-mapper (included.client.audience=
kubernetes) so tokens carry aud=[headlamp, kubernetes] -- accepted by both
Headlamp's own verifier and the apiserver, leaving the apiserver config
untouched. A groups mapper emits the `groups` claim the apiserver maps to RBAC.

- platform/headlamp: namespace, HelmRepository/HelmRelease, SOPS OIDC secret
  (externalSecret envFrom), default-deny NetworkPolicy + Cilium ingress
  (gateway)/egress (apiserver+Keycloak) policies, README. Wired into
  platform/kustomization.yaml (platform tier).
- platform/headlamp/httproute.yaml wired into platform-config (Gateway API CR,
  dependsOn platform), modeled on grafana-httproute.
- keycloak-realm-import: add `headlamp` confidential client + groups/audience
  mappers + HEADLAMP_CLIENT_SECRET placeholder. Client also created LIVE via
  kcadm (realm import no-ops on the existing realm).
- keycloak-realm-clients.sops: add headlamp-client-secret (mirrored into
  headlamp-oidc-credentials clientSecret). Both encrypted to the repo age key.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 11:44:33 -05:00
jakman85 b243c753b1 Merge: migrate proxmox_virtual_environment_download_file -> proxmox_download_file (ADR-007, moved block) 2026-08-28 11:23:06 -05:00
jakman85andClaude Opus 4.8 0cf0c669f4 tofu: migrate to non-deprecated proxmox_download_file (ADR-007)
bpg/proxmox deprecates proxmox_virtual_environment_download_file ("Use
proxmox_download_file instead. This resource will be removed in v1.0."),
the short-name rename from ADR-007. Both are the same dual-registered
Framework resource, so rename the resource type + import_from reference
and add a `moved` block. OpenTofu 1.9 drives the provider's MoveState
(migration.PrefixMoveState) to transfer state in place: same id, no
re-download of the Debian image, no change to the VMs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 11:22:06 -05:00
jakman85 ffb439d59e Merge fix/reconcile-apps-goldens: reconcile goldens to --no-image-automation + enforcing render-golden gate stage 2026-08-28 11:13:07 -05:00
jakman85andClaude Opus 4.8 fe0ee698c4 fix(apps): reconcile goldens to one canonical render + enforce render==golden gate
The committed apps/ goldens were a MIX of two render flag sets: 8 services
were rendered with `--no-image-automation` (no image CRs) while gitea and
nextcloud carried stale ImageRepository/ImagePolicy CRs from a render WITHOUT
the flag. Neither single command reproduced all goldens, and the gate did not
catch it.

Part A — canonical render:
  python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation
`--no-image-automation` is canonical: image automation is authored-but-DISABLED
(DECISIONS-LOG; image-reflector/-automation controllers not installed), and
every app was onboarded via this flag. Re-render drops the ImageRepository +
ImagePolicy objects from gitea + nextcloud only (52 line deletions, purely the
trailing image-automation blocks). No functional content changed — the
nextcloud postStart hardening and gitea OIDC hook are untouched.

Part B — enforcement:
  ci/render-golden.sh renders every onboarded catalog service with the canonical
  command into a temp dir and FAILS on ANY diff vs committed apps/ (missing
  golden or content drift), across all 10 services (not just the 3 L1 spot-checks).
  Not-onboarded catalog entries (keycloak, vm-demo) are excluded per DESIGN-waves.
  Wired as a required no-cluster stage in ci/gate.sh, ci/Makefile, and both the
  live (.gitea/workflows/gate.yml) and dormant (merge-gate.yaml) pipelines.

Proof: tampering a golden makes the stage exit 1 (blocks merge); reverting
restores exit 0. `make -C ci gate` green in the devcontainer.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 11:11:52 -05:00
jakman85 85338a8144 Merge fix/nextcloud-logout-admin: post-logout redirect URI + hardened admin-promotion hook 2026-08-27 20:19:51 -05:00
jakman85andClaude Opus 4.8 14521d37d3 fix(nextcloud-sso): apply step-(d) retry hardening at SOURCE (catalog)
apps/wave-1/nextcloud.yaml is GENERATED from catalog/nextcloud.yaml by
catalog/render.py, so the previous commit hardened only the rendered golden —
the next render would have reverted step (d) to the old single-pass version.

Apply the same verify-and-retry hardening to the postStart hook in
catalog/nextcloud.yaml (the source of truth). Re-rendered with
`render.py --all catalog -o apps --wave-layout`; apps/wave-1/nextcloud.yaml is
byte-identical to the golden committed in the previous commit (render round-trip
clean), so no apps/ change is needed here.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 20:18:51 -05:00
jakman85andClaude Opus 4.8 7f86e1099b fix(nextcloud-sso): register post-logout redirect URI + harden admin promotion
Two SSO follow-ups the operator hit after Nextcloud OIDC login worked.

1. Sign-out "redirect failed". On logout user_oidc redirects the browser to
   Keycloak's end_session_endpoint with post_logout_redirect_uri=<Nextcloud
   base '/'> and client_id=nextcloud but NO id_token_hint (provider
   sendIdTokenHint=0). Keycloak then validates that redirect against the
   client's post.logout.redirect.uris attribute, which was empty -> "Invalid
   redirect uri" -> the operator's "redirect failed". Register
   https://nextcloud.home.keithjackson.dev/* on the nextcloud client
   (attributes.post.logout.redirect.uris). Applied live via kcadm and captured
   here so git stays authoritative (a RealmImport no-ops on the existing realm).

2. postStart step (d) mirrors platform-admins -> built-in admin group, but the
   previous single best-effort pass ran occ group:adduser once with output
   swallowed and no retry. At early boot (container Started ~9s after create)
   that call can transiently fail (DB / maintenance race), silently dropping the
   promotion until the next restart. Harden step (d) with a verify-and-retry
   loop (like step (c)): re-read group:list and re-add any platform-admins
   member still missing from admin until verified (bounded ~30s), no-op for
   existing members.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 20:13:45 -05:00
jakman85 7d93decd61 tofu: fill encrypted secrets 2026-08-27 19:53:46 -05:00
jakman85 0996c1da0d Merge feature/tofu-sops-secrets: SOPS-encrypted tofu secrets + tf.sh wrapper 2026-08-27 19:09:57 -05:00
jakman85andClaude Opus 4.8 216bea2a16 tofu: move secrets into committed SOPS/age file + tf.sh wrapper
Replace the gitignored plaintext infra/tofu/.r2.env and backend.hcl with a
single committed, SOPS/age-encrypted dotenv (secrets.sops.env) plus a thin
wrapper (tf.sh), so tofu runs with no manual secret entry and secrets live
encrypted in git like the repo's other secrets.

- .sops.yaml: creation rule encrypting infra/tofu/secrets.sops.env (all values)
  to the existing age recipient.
- secrets.sops.env: encrypted, PLACEHOLDER values only. Holds R2 S3 keys, the
  state-encryption passphrase (TF_STATE_PASSPHRASE scalar), and the Proxmox API
  token/endpoint. Operator fills real values via `sops`.
- tf.sh: sops exec-env decrypts the dotenv, exports PROXMOX_VE_*/AWS_*, builds
  the multi-line TF_ENCRYPTION HCL from TF_STATE_PASSPHRASE, and runs tofu
  (injecting -backend-config=backend.r2.hcl on init). Proxmox token stays env-only,
  never a tofu variable/state.
- backend.r2.hcl: committed non-secret R2 bucket/key/endpoint (account id is not
  a credential), passed to `tofu init`.
- Retire .r2.env/backend.hcl(.example): drop the committed templates; keep the
  gitignore lines as a safety net for leftover local plaintext.
- Update backend.tf/README/REMOTE-STATE for the new day-2 flow and DR story
  (repo + age key is sufficient).

Verified: placeholder file round-trips sops -e/-d with the repo age key; tf.sh
exports the expected VAR NAMES and builds valid TF_ENCRYPTION HCL (stub tofu, no
real plan/secrets).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 19:07:52 -05:00
jakman85 01f3dd0044 Merge fix/nextcloud-oidc-reach: allow_local_remote_servers for private-IP IdP + platform-admins→admin (#2,#5) 2026-08-27 19:03:30 -05:00
jakman85andClaude Opus 4.8 7f92adfac6 fix(nextcloud): allow SSRF to private IdP + promote platform-admins to admin
Task 1 — browser SSO "Could not reach OpenID Connect provider" (confirmed root
cause, not guessed). keycloak.home.keithjackson.dev resolves split-horizon to the
PRIVATE Cilium gateway VIP (192.168.1.53). Nextcloud's SSRF guard refuses its own
server-side HTTP requests to RFC1918 addresses, so user_oidc's discovery/JWKS/token
fetch AT LOGIN throws OCP\Http\Client\LocalServerException "violates local access
rules" -> surfaced to the user as "Could not reach the provider". Proven from the
live pod: nextcloud.log carries the exact LocalServerException from
DiscoveryService::obtainDiscovery with Guzzle option allow_local_address:false; a
Nextcloud-context IClientService->get() of the discovery URL returns
LocalServerException with the flag false and HTTP 200 with it true (A/B toggled).
The postStart (b) curl check passed regardless because it uses system libcurl, not
Nextcloud's guard — which is why the provider registered yet login still failed.

Fix (reproducible, survives pod rolls): the postStart hook now runs
`occ config:system:set allow_local_remote_servers --value=true --type=boolean`
right after the install/maintenance wait, on every start.

Task 2 — promote Keycloak `platform-admins` members to Nextcloud admins (#5).
user_oidc 8.11 has NO native group->admin mapping (occ user_oidc:provider offers
group PROVISIONING and a login WHITELIST only), so the hook mirrors the provisioned
`platform-admins` membership into the built-in `admin` group idempotently (occ
group:list --output=json parsed via php argv; occ group:adduser admin per member).
A user is promotable after their first OIDC login provisions them + the next pod
start (or one manual `occ group:adduser admin <uid>`). The LOCAL admin stays as
break-glass (docs/15).

apps/wave-1/nextcloud.yaml re-rendered from catalog (single-file render; byte-diff
confined to the embedded hook). Rendered hook passes `sh -n`; parse step validated
in-pod. `make -C ci gate` green (lint/l0/l1; conformance + spec-immutability skip:
no cluster / spec repo unmounted).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 19:00:09 -05:00
jakman85 318f85de11 Merge fix/plex-config-mountpath: mount config PVC at /config (Plex was writing ephemeral /config, unclaimed) 2026-08-27 18:54:44 -05:00
jakman85andClaude Opus 4.8 d835a27875 fix(plex): mount config PVC at /config (image's real config dir)
The plexinc/pms-docker image writes its identity/claim token and the
Library metadata DB under /config, but the config PVC was mounted at the
renderer default /data/config. Plex therefore wrote all state to the
container's EPHEMERAL layer: the server stayed UNCLAIMED (servers.plex.tv
returns 401 with an empty X-Plex-Token) and lost everything on restart,
which is why seerr's "add Plex" failed even with SSL off.

Set the config volume mountPath to /config in catalog/plex.yaml and
re-render apps/wave-0/plex.yaml (only the mountPath line changes; all
other services byte-identical). No fsGroup/securityContext needed:
verified in-cluster the image inits as root (s6-svscan PID 1), chowns
/config to the plex user, then drops privileges (the "Plex Media Server"
process runs as plex) — root can chown the mounted PVC regardless of
fsGroup.

PLEX_CLAIM auto-claim is intentionally NOT wired: the env secretKeyRef
schema has no `optional`, so a placeholder/absent claim Secret would
crashloop the pod. The claim is an operator step (UI sign-in after the
roll, or add a PLEX_CLAIM env from a fresh https://plex.tv/claim token).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 18:53:46 -05:00
jakman85andClaude Opus 4.8 13b1f1d9a4 docs(decisions): record 2026-08-27 session (R2 state, seerr, app-SSO, oauth2-proxy tier, R-STATE-02)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 18:24:14 -05:00
jakman85 0fcbf32742 Merge fix/oauth2-proxy-post-platform-tier: move oauth2-proxy to post-platform tier (breaks cold-start deadlock) 2026-08-27 18:15:25 -05:00
jakman85 1f67a8ae72 Merge feature/conformance-r-state-02-node-loss: L3 node-loss test (R-STATE-02, closes last safety-critical gap) 2026-08-27 18:13:33 -05:00
jakman85andClaude Opus 4.8 960cab937b fix(flux): move oauth2-proxy to a post-platform tier (dependsOn platform-config)
oauth2-proxy is a leaf forward-auth service that depends on Keycloak +
trusted wildcard TLS + split-horizon DNS. It lived in the gating `platform`
Flux Kustomization (wait:true), so on a cold start it Failed until those
dependencies existed, keeping the whole `platform` tier not-Ready. That in
turn blocked `platform-config` — which brings up the very Keycloak instance,
wildcard Certificate and L2 VIP oauth2-proxy needs (and previously carried
oauth2-proxy's own httproute/servicemonitor) — from ever reconciling. A
self-gating cold-start deadlock, twice-proven (DECISIONS-LOG sessions 7 & 9).

Break the cycle by moving the whole oauth2-proxy component out of the
`platform` tier into its own non-gating post-platform Flux Kustomization:

- clusters/base/oauth2-proxy.yaml: new Kustomization -> ./platform/oauth2-proxy,
  dependsOn platform-config, wait:false, substituteFrom cluster-vars.
- clusters/base/kustomization.yaml: reference the new Kustomization.
- platform/kustomization.yaml: drop the oauth2-proxy component (no longer in
  the gating tier).
- platform/oauth2-proxy/kustomization.yaml: pull in httproute + servicemonitor
  so the full component travels together.
- platform-config/kustomization.yaml: drop the in-place oauth2-proxy
  httproute/servicemonitor refs (moved with the controller).
- clusters/dev/kustomization.yaml: add the SOPS decryption patch for the new
  oauth2-proxy Kustomization (it now owns the encrypted credentials Secret that
  the `platform` Kustomization used to decrypt).

Now `platform` reaches Ready without oauth2-proxy; platform-config brings up
Keycloak/TLS/DNS; then oauth2-proxy reconciles against dependencies that
already exist. wait:false guarantees its health can never gate anything above
it. No config of oauth2-proxy itself changed — only its reconciliation tier.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 18:12:43 -05:00
jakman85andClaude Opus 4.8 fdf5c69adc test(conformance): add L3 node-loss test for R-STATE-02 (Longhorn node-loss resilience)
R-STATE-02 was the last safety-critical requirement with no verifying test,
exempted via DOCUMENTED_GAPS. Author the L3 fault-injection test that proves the
guarantee and remove the exemption.

tests/l3-fault-injection/node-loss/chainsaw-test.yaml:
  GIVEN a 3-replica Longhorn volume with written data attached on node N,
  WHEN  N is removed from service (cordon + evict the attached workload, forcing
        the volume to detach from N),
  THEN  a fresh workload on a SURVIVING node re-attaches the same volume and reads
        the canary intact — volume stays available, cluster reconverges.

Safe-by-construction for the 3-node embedded-etcd dev cluster:
  - Disruption scoped to EXACTLY ONE node, at the scheduling layer only (cordon +
    evict our own pod). kubelet/etcd stay up — etcd quorum is never touched.
  - Never `kubectl drain`; only our own writer pod is deleted, so no unrelated or
    system pod is disturbed.
  - Victim node labelled conformance.home.k3s/cordoned-by=<test-ns>; `finally`
    ALWAYS uncordons every node with THAT label and removes it — restored even on
    failure/timeout, and never touches an operator-cordoned node.
  - Self-skips green until a longhorn StorageClass AND >= 3 Ready nodes exist.

Bookkeeping:
  - run.sh: remove R-STATE-02 from DOCUMENTED_GAPS; add node-loss to CONF_HEAVY_TESTS
    (serial pass, matches the other Longhorn/KubeVirt L3 tests).
  - incluster/rbac.yaml: enable the scoped nodes get/list/patch rule the test needs
    (cordon/uncordon/label); no eviction rights (no drain).
  - README.md / coverage.py docstring: refresh the documented-gap example.

Coverage: 36/51 -> 37/51 (70.6% -> 72.5%); safety-critical gaps 1 -> 0. Only
R-STATE-02 changed ([] -> node-loss); no other requirement or golden touched.

NOTE: LIVE execution is DEFERRED to an operator-chosen maintenance window. This
change AUTHORS + statically validates the test (renders, chainsaw-lints, harness
selects it, coverage updates). The static gate runs L2/L3 as SKIP without a
cluster (expected). The node-disruption test was NOT run live.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 18:12:33 -05:00
jakman85andClaude Opus 4.8 d34c639269 fix(oidc): make gitea + nextcloud OIDC login-source registration persist
The Keycloak OIDC postStart registration for gitea and nextcloud ran but
never persisted, leaving `gitea admin auth list` empty and nextcloud's
user_oidc with "No providers configured" despite the apps installing/
enabling and the client secret being present.

Two distinct root causes, confirmed by running the registration manually
against the live warm pods:

- nextcloud: a QUOTING BUG, not a race. The OCC() wrapper runs
  `su -c "php occ $*"`, which word-split `--scope="openid email profile"`
  back into three tokens, so occ saw `email profile` as extra positional
  args -> "Too many arguments to user_oidc:provider" -> exit 1, swallowed
  by `|| echo failed`. Failed on EVERY boot. Fixed by running the provider
  call as ONE `su -c` with the scope single-quoted so www-data's shell
  keeps it a single argument; keep the OCC() wrapper only for single-token
  subcommands.
- gitea: a first-boot RACE. The direct `admin auth add-oauth` persists once
  the app/network is warm (verified live), but fired too early at postStart
  and its failure was swallowed (`|| echo ... >&2`, exit 0).

Hardening applied to both catalog sources (rendered into apps/wave-*):
- wait (bounded ~90s) for the Keycloak discovery doc to return HTTP 200;
- nextcloud also waits for `installed: true` AND `maintenance: false`;
- register idempotently (create-or-update) then VERIFY the source/provider
  actually persisted, retrying the whole cycle (guards exit-0-but-nothing-
  stuck);
- on give-up, log LOUDLY to stderr AND a marker file on the persistent
  volume (/data, /var/www/html) instead of swallowing — next failure is
  visible;
- never crashloop: missing-secret guard retained, break-glass local admin
  stays usable.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 17:41:54 -05:00
jakman85andClaude Opus 4.8 2919a1d72e chore: gitignore .claude/ (agent worktrees pollute lint + git add -A)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 17:19:42 -05:00
jakman85 76ba88a51e Merge feature/oidc-sso-apps: native Keycloak OIDC SSO for gitea, nextcloud, semaphore 2026-08-27 17:19:01 -05:00
jakman85andClaude Opus 4.8 f163026954 feat(nextcloud,semaphore): wire native Keycloak OIDC SSO (break-glass admins retained)
Extends the SSO work to the two remaining `auth: oidc` apps still on local-admin
logins, each as a native confidential client of the Keycloak `homelab` realm with
its local admin kept as break-glass (docs/15 availability caveat; R-IDP-02/06).

Realm (source of truth): add the `nextcloud` and `semaphore` confidential clients
to platform/keycloak/keycloak-realm-import.yaml (modeled on grafana/gitea) + their
NEXTCLOUD_CLIENT_SECRET / SEMAPHORE_CLIENT_SECRET placeholders. As before, a
KeycloakRealmImport no-ops on an already-existing realm, so both live clients were
ALSO created via kcadm (nextcloud e95049ed-ca10-4085-97d9-4bbef495fe61, semaphore
1835bfe9-b125-4026-8f69-33557519cf4a); the YAML keeps git authoritative.

Secrets (SOPS/age): add nextcloud-client-secret + semaphore-client-secret to
keycloak-realm-clients.sops.yaml (keycloak ns). Mirror the nextcloud secret as
oidc-client-secret into nextcloud-bootstrap (nextcloud ns). For semaphore, the
whole SEMAPHORE_OIDC_PROVIDERS JSON (which embeds the client secret) is held
encrypted as oidc-providers-json in semaphore-bootstrap (semaphore ns). All values
verified == the live clients; existing keys untouched; no plaintext in git.

App config (catalog source + re-render):
- nextcloud: add NEXTCLOUD_OIDC_CLIENT_SECRET env + an idempotent postStart hook
  that installs/enables user_oidc and runs `occ user_oidc:provider keycloak`
  (create-or-update; secret via --clientsecret-env so it never hits argv; occ as
  www-data; group provisioning on the `groups` claim). Syntax verified against the
  running user_oidc 8.11.0 on Nextcloud 31.0.14; su-to-www-data env passthrough
  verified. Missing-secret/not-installed guards prevent crashloops.
- semaphore: add SEMAPHORE_OIDC_PROVIDERS env sourced from the encrypted
  oidc-providers-json (provider_url=realm issuer/auto-discovery, redirect
  /api/auth/oidc/keycloak/redirect). Mechanism verified against semaphore v2.19.10
  source (util ConfigType oidc_providers env tag + OidcProvider struct + router
  redirect path); no kubelet service-link env collision.
apps/wave-1/{nextcloud,semaphore}.yaml re-rendered via
`render.py --no-image-automation` (diffs are env/lifecycle-only).

Verified: realm import Done=True/HasErrors=False and platform-config
Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run; live
homelab clients now include gitea/nextcloud/semaphore; static gate (lint/l0/l1)
green. Browser SSO login for each still needs a human check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 17:17:13 -05:00
jakman85andClaude Opus 4.8 87388712f0 feat(gitea): wire native Keycloak OIDC SSO (break-glass admin retained)
Wires the bootstrapping-critical Gitea git host to the Keycloak `homelab` realm
as a native confidential OIDC client, moving it off the local-admin-only login
while keeping the local admin as the break-glass path (docs/15 availability
caveat; R-IDP-02/06).

Realm (source of truth): add the `gitea` confidential client to
platform/keycloak/keycloak-realm-import.yaml (modeled on grafana) + its
GITEA_CLIENT_SECRET placeholder sourced from the keycloak-realm-clients Secret.
NOTE: a KeycloakRealmImport no-ops on an already-existing realm, so the live
client was ALSO created via kcadm (id 70a6ca60-317a-42ce-8331-a60e380838ee);
the YAML keeps git authoritative for a rebuild.

Secrets (SOPS/age, never plaintext): add gitea-client-secret to
platform/keycloak/keycloak-realm-clients.sops.yaml (keycloak ns) and mirror the
same value as oidc-client-secret into apps/wave-0/gitea-bootstrap-secret.sops.yaml
(gitea ns) so the app can read it in-namespace. Both == the live client secret.

App config (catalog source + re-render): catalog/gitea.yaml gains the
GITEA_OIDC_CLIENT_SECRET env (from the mirrored Secret), [oauth2_client]
auto-registration/account-linking app.ini env, and an idempotent postStart step
that registers the "keycloak" OAuth2 login source (auto-discovery; groups claim;
platform-admins -> Gitea admin). apps/wave-0/gitea.yaml re-rendered via
`render.py --no-image-automation`.

Verified: realm import Done=True/HasErrors=False and platform-config
Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run;
static gate (lint/l0/l1) green. Browser SSO login still needs a human check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 17:01:46 -05:00
jakman85andClaude Opus 4.8 7b8777c1e1 fix(seerr): add fsGroup podSecurityContext to resolve non-root PVC CrashLoop
Seerr v3.4.1 (ghcr.io/seerr-team/seerr) runs as the non-root user node:node
(UID/GID 1000), unlike the old root-running sctx/overseerr image. Its root-owned
Longhorn RWO config PVC at /app/config was not writable by UID 1000, so the pod
CrashLoopBackOff'd with:

  Error: EACCES: permission denied, mkdir '/app/config/logs/'

Add a podSecurityContext with fsGroup=1000 (plus runAsUser/runAsGroup/runAsNonRoot)
to the seerr ServiceDefinition — the same schema 1.3.0 mechanism semaphore uses.
Longhorn's CSIDriver fsGroupPolicy: ReadWriteOnceWithFSType applies fsGroup to this
RWO filesystem volume, group-owning the PVC to GID 1000 so the process can write.
Re-rendered apps/wave-2/seerr.yaml from the catalog source.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 16:58:17 -05:00
jakman85andClaude Opus 4.8 df947b33d0 infra(tofu): fix backend.tf HCL header + document encryption-adoption migration
backend.tf began with a stray YAML `---` doc separator (invalid HCL) that broke
`tofu` parsing. Remove it. Also document the one-time `unencrypted` fallback method
needed to adopt encryption on pre-existing plaintext state (add for the migrate,
remove after), so the record is complete. State migration to R2 verified: 4/4
resources read back through the encrypt-only method (proves ciphertext at rest).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 16:31:23 -05:00
jakman85 7b16621585 Merge feature/tofu-remote-state-r2: off-cluster R2 encrypted tofu state scaffolding 2026-08-27 16:20:26 -05:00
jakman85andClaude Opus 4.8 8b0dcbf33b fix(seerr): move to maintained Seerr image and correct config mountPath
Two operator-flagged defects in the seerr ServiceDefinition, fixed at source
(catalog/seerr.yaml) and re-rendered into apps/wave-2/seerr.yaml:

- Image: sctx/overseerr:latest (original Overseerr, unmaintained) ->
  ghcr.io/seerr-team/seerr:v3.4.1. Overseerr and Jellyseerr have merged into
  the actively maintained Seerr project (seerr-team/seerr, docs.seerr.dev);
  ghcr.io/seerr-team/seerr is the officially recommended image. Pinned to the
  current stable tag v3.4.1 per the arr-stack convention (sonarr/radarr/
  qbittorrent/gitea pin explicit tags; R-UPD-01). update.range bumped
  ">=1.33.0" -> ">=3.4.0" to track the Seerr 3.x line. Plex-native (native
  auth) support is retained.

- Config mountPath: /data/config -> /app/config. Overseerr/Jellyseerr/Seerr
  store settings + request/cache DB at /app/config; the previous /data/config
  left the config PVC effectively unused (config not persisted across
  restarts). Set via the schema-supported storage[].mountPath field.

Dependencies (sonarr:8989, radarr:7878, plex:32400), NetworkPolicies,
public exposure, and native auth are unchanged. Render == golden verified;
lint, schema, and L1 render checks green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 16:15:00 -05:00
jakman85andClaude Opus 4.8 485b9e3888 infra(tofu): scaffold off-cluster R2 remote state (encrypted)
Local-only tfstate meant losing the workstation would strand a rebuild (apply
would try to recreate the 3 live nodes). Move state to Cloudflare R2, which sits
OUTSIDE the Proxmox->VM->cluster->Gitea chain, so a full cluster rebuild can still
read it; encrypted at rest via OpenTofu native AES-GCM.

Adds a partial s3 backend (R2 skip-flags), gitignored backend.hcl/.r2.env for the
account-specific + secret bits, their .example templates, and a migration+DR runbook.
Locking (use_lockfile) deferred to an OpenTofu >=1.10 bump (1.9.0 lacks it).
Migration (tofu init -migrate-state) runs once the operator provisions the R2 bucket.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 15:57:12 -05:00
jakman85 91c82d2756 Merge docs/reconcile-with-reality: docs match reality; merge gate green again
Reconciles the documentation with the deployed platform (the root README still
claimed Phase 0 / no cluster; the infra docs described a retired libvirt cluster;
DECISIONS-LOG.md had gone ~85 commits stale), and fixes the two independent
defects that had left the merge gate BLOCKED on main:

  - yamllint commas errors in the hand-authored apps/vms/unifi manifests
    (whitespace only; kustomize output byte-identical);
  - ci/touched-requirements.sh scanning whole file contents instead of the diff,
    which mis-attributed requirements to changes that never touched them and made
    appending to the decision log fail CI.

Gate verified in the devcontainer: spec-immutability / lint / l0 / l1 green,
MERGE GATE: PASS.
2026-08-27 00:12:02 -05:00