Commit Graph
4354 Commits
Author SHA1 Message Date
k.rennerandClaude Opus 4.7 4a04448589 fix(monitoring): alloy-syslog ETP=Cluster + add Loki ingress for logcli
ETP=Local broke cross-VLAN reach because workstations on VLAN 50 traverse
the UDM gateway L3, which lands traffic on whichever node it has ARP for —
with Local that node may not host the backend pod, producing TCP RST and
silent UDP drop. Match the default services-pool house pattern (ingress-nginx,
kube-dns-lb) which both run ETP=Cluster.

Drop the source_ip label promotion since under ETP=Cluster it would carry
a cluster node IP rather than the sender's IP.

Add an ingress-nginx Ingress for the in-cluster Loki at loki.k.renner.uno
gated by HTTP basic auth (htpasswd secret in sops) and TLS via
letsencrypt-prod, so the workstation can point logcli at the K8s Loki via
LOKI_ADDR/LOKI_USERNAME/LOKI_PASSWORD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:35:37 +02:00
k.rennerandClaude Opus 4.7 9a73cf6db7 fix(multus): vendor v4.2.4-thick daemonset; drop HelmRelease
The HelmRelease pointed at https://k8snetworkplumbingwg.github.io/helm-charts
which 404s — the upstream helm-charts repo only ships charts for
local-clone install, not as a published Helm repository. Fetched in
fallback by Flux's source-controller failed and infrastructure stalled
in Reconciling, blocking everything that dependsOn it (matter-server
included).

Vendor the upstream daemonset manifest pinned to v4.2.4. Going thick
because v4.2.4-thin and unsuffixed v4.2.4 are not published as image
tags — only v4.2.4-thick / v4.2.4-debug exist for the release. The
thick variant chroots into /hostroot to exec CNI plugins; Talos's
/opt/cni/bin is writable (Cilium already uses it) so this is fine.

Header comment captures the why-vendored / why-thick / Talos-OK
reasoning so the next reader doesn't have to re-derive it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:18:10 +02:00
k.rennerandClaude Opus 4.7 7e09b1bb32 fix(matter-server): drop hostNetwork; macvlan via multus on v30iot
The prior agent's hostNetwork: true was the wrong abstraction. Earlier
in this PR I tried a Cilium-L2 LoadBalancer Service on 10.2.30.226,
but that only forwards a single TCP port (5580 ws) and does not give
the pod L2 presence on the IoT subnet — outbound is still cluster pod
network, no multicast, no Matter operational ports. Matter device
commissioning and discovery need real mDNS send/receive on the IoT L2.

Install multus-cni alongside Cilium and attach matter-server to a
NetworkAttachmentDefinition that does macvlan on k8s-edge's eth1 (the
v30iot bridge — verified via `talosctl -n k8s-edge get routes`,
10.2.30.0/24 is connected on eth1). Static IPAM pins 10.2.30.226 with
no gateway, so default-route traffic stays on the Cilium-managed
primary interface and only IoT-subnet traffic crosses net1.

The wiki note hostnetwork-app-workload-antipattern still applies, but
unlike the otbr precedent (commit 1972c9b) — where ClusterIP alone is
sufficient — matter-server actually does need IoT-VLAN reach, just not
via hostNetwork. multus is the documented escape hatch the wiki has
flagged but never exercised in this homelab; first install lands here.

iot-pool ledger now: .224 = home-assistant (LB), .225 = mosquitto
(LB), .226 = matter-server (multus, *not* a Cilium LB). LB Services
all pin explicit IPs via lbipam.cilium.io/ips so no auto-allocation
collides with the multus IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:04:06 +02:00
k.rennerandClaude Opus 4.7 32a7799e2b feat(monitoring): add K8s syslog→Loki receiver via Alloy
Stand up a dedicated alloy-syslog Deployment in monitoring that listens
on UDP/514, UDP/1514 (legacy parity), TCP/514 and TCP/6514+TLS and
writes into the in-cluster Loki SingleBinary.

Exposed as a Cilium L2 LoadBalancer pinned to 10.2.40.84 and published
as syslog.k.renner.uno via external-dns; that meant adding `service` to
external-dns-unifi sources (was ingress-only). TLS cert issued by
letsencrypt-prod through cert-manager.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:56:15 +02:00
k.rennerandClaude Opus 4.7 1972c9b0af fix(otbr): drop hostNetwork — wrong abstraction for this pod
OTBR's only outbound need is the spinel TCP connection to
slzb-thread.renner.uno:6638. HA and matter-server reach OTBR's REST
API via the ClusterIP Service. None of that requires the host's
network namespace. The wiki's hostnetwork-app-workload-antipattern
applies here.

If border-router coordination on the IoT VLAN ever becomes a real
requirement, the right answer is a Cilium-L2 LoadBalancer pinned IP
(same shape as home-assistant-iot / mosquitto-iot), not hostNetwork.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:23:11 +02:00
k.rennerandClaude Opus 4.7 0deb05041b fix(otbr): RADIO_URL uses :6638 (Serial-over-IP), not 9999
SLZB exposes the Thread RCP via Serial-over-IP at port 6638 per the
smlight docs. I had 9999 from a placeholder I made up in a comment
yesterday and never verified — port 9999 is closed on the device.
6638 is open (`nc -z` confirmed before commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:52:44 +02:00
k.rennerandClaude Opus 4.7 1e03a7ec8a fix(otbr): also FIREWALL=0 — ip6tables filter table missing on Talos
After NAT64=0, the next entrypoint stage that fails is firewall_start
(otbr-firewall service) which runs ip6tables -t filter ... — that
table also doesn't exist on Talos. The firewall sets up
mesh→outside ingress filtering; routing itself doesn't need it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:36:11 +02:00
k.rennerandClaude Opus 4.7 69d7315d11 fix(otbr): disable NAT64/DNS64 — entrypoint dies on Talos iptables
The otbr image's nat64_start → nat44_start init does
`iptables -t nat/mangle/filter ...` and Talos doesn't load those
kernel modules by default → "table does not exist" → entrypoint
exits 1. HA only consumes OTBR's REST API on :8081, not the
Thread→IPv4 NAT path, so disable both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:24:57 +02:00
k.rennerandClaude Opus 4.7 7f1c45fa28 fix(otbr): point RADIO_URL at slzb-thread.renner.uno
Replace TBD-SLZB-HOSTNAME placeholder. Note: yesterday's
investigation found OTBR's NAT44 init also fails on Talos with
iptables "table does not exist" errors — that's a separate
kernel-module issue that may still block actual Thread border
routing even with the correct radio URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:21:50 +02:00
k.rennerandClaude Opus 4.7 9c5c0afaee feat(home-assistant): hard-disable nut integration
Synology DSM's UPS server uses source-IP whitelisting as the auth
mechanism — there's no per-user/per-key access control, just an IP
allow-list. Source IPs are transient (k8s-edge eth0 IP, node
migrations, Cilium egress changes) so the integration breaks on
every infra change. Not worth chasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:18:52 +02:00
k.rennerandClaude Opus 4.7 14e4d4c433 feat(home-assistant): DEV_MODE-disable philips_airpurifier_coap + siku
Same dual-run pattern as roomba. Both devices sit on the IoT VLAN,
both are reachable from the pod, but the legacy LXC HA on VM 103 is
still talking to them — so k8s HA hits "Failed to connect" /
TimeoutError on every retry until cutover. Hide the noise via
DEV_MODE; flip false at cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:05:35 +02:00
k.rennerandClaude Opus 4.7 e0835b6f27 feat(home-assistant): point Müllabfuhr calendar at in-cluster ical-proxy
The remote_calendar entry titled "Müllabfuhr" pointed at the public
hostname ical-proxy.renner.uno, which hairpins out through the
Cloudflare tunnel and back into ingress-nginx. Repoint at the
in-cluster Service ical-proxy.apps.svc.cluster.local directly.

Network-policy adjustments to permit the new path:
- home-assistant-ns: add `apps` namespace to egress
- apps/default-deny-ingress: add `home-assistant` to ingress

Both edits stay namespace-scoped (no broader-than-needed selectors)
and follow the existing per-ns CNP pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:54:56 +02:00
k.rennerandClaude Opus 4.7 08c68abfec fix(home-assistant): clear HAOS-supervisor flags on Matter/Z-Wave + DEV-disable Z-Wave
Matter and Z-Wave JS setup were failing with:
  Error setting up entry Matter for matter
   ... _async_ensure_addon_running → get_supervisor_client

Both integrations check `if use_addon:` and call into the HAOS Supervisor
addon manager when true. The entries had use_addon=true and
integration_created_addon=true left over from the VM 103 HAOS install.
On HA Container there's no supervisor → setup crashes.

Fix: extend reconcile_entry with an optional "residue keys" parameter
(matching the MQTT TLS-residue pattern) and pass
["use_addon","integration_created_addon"] for matter and zwave_js.
With those keys gone, HA's early-return path uses the websocket URL
directly.

Also DEV_MODE-disable zwave_js: even with the residue cleaned up, its
deployment is replicas=0 (cutover-pending) so its Service has no
endpoints — connection fails on every retry. Same justification as
edl21 and esphome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:11:20 +02:00
k.rennerandClaude Opus 4.7 830eec1e9f fix(home-assistant): single DEV_MODE switch for cutover-gated integrations
Replace per-integration DEV_DISABLE_* envs with a single DEV_MODE
switch covering every integration that's blocked until cutover:

- esphome: devices not reachable from the pod network yet
- edl21: stromzaehler USB not mounted yet
- roomba: Roomba MQTT broker is single-client and the legacy HA on
  VM 103 still owns the connection — k8s HA gets ConnectionRefused
  on every reconnect

Roomba moves from a hard-disable to DEV_MODE-gated; the device IS
reachable from the pod network via the IoT-VLAN egress, the issue is
duplicate-client rejection while we're dual-running. s3_compatible
stays hard-disabled — that's a code-bug (botocore register_feature_id
import), not a cutover-state thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:05:16 +02:00
k.rennerandClaude Opus 4.7 ec6c774468 feat(home-assistant): config-reconciler disables broken/pending integrations
Quiet four sources of log noise the user surfaced this morning by
keeping their config_entries disabled_by="user":

- s3_compatible: HACS integration imports register_feature_id from
  botocore.useragent, removed in newer botocore — fails setup on every
  boot. Hard-disabled until the upstream HACS plugin or botocore is
  fixed.
- roomba: device on the IoT VLAN unreachable from the pod network in
  the current configuration; reconnect loop spams ConnectionRefused.
  Hard-disabled until the path is fixed or the device is back online.
- esphome: existing DEV_DISABLE_ESPHOME flag, refactored through the
  new disable_entries helper.
- edl21: new DEV_DISABLE_EDL21 flag (default true) for the stromzaehler
  SML integration; sml.asyncio spams "/dev/ttyUSB0: No such file" until
  the USB is mounted at cutover. Flip to false then.

Adds a disable_entries shell helper next to reconcile_entry. Both
take a jq filter expression so per-domain disables are one-liners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:02:44 +02:00
k.rennerandClaude Opus 4.7 5105163414 feat(home-assistant): config-reconciler also points sibling integrations at k8s DNS
The HAOS-supervisor addon names (core-whisper / core-matter-server /
core-openthread-border-router / core-zwave-js / core-openwakeword /
core-speech-to-phrase) carried over from the VM 103 install but don't
resolve in the cluster. Add a reconcile_entry helper to the sidecar
and use it to repoint each existing config_entry at the matching k8s
Service DNS:

  wyoming/Whisper          → whisper.home-assistant.svc.cluster.local:10300
  wyoming/openWakeWord     → openwakeword...:10400
  wyoming/Speech-to-Phrase → speech-to-phrase...:10300
  matter                   → ws://matter-server...:5580/ws
  otbr                     → http://otbr...:8081
  zwave_js                 → ws://zwave-js...:3000

Each merge is shallow on a narrow key set (host/port for wyoming, url
for the rest) so HA-managed extras like Z-Wave's s2_*_keys survive
untouched. The helper only patches existing entries; nothing creates
them. Drift detection uses jq -cS canonical key ordering — same
discipline that finally stabilised the MQTT reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:23:29 +02:00
k.rennerandClaude Opus 4.7 12199b697d feat(network-policies): bring home-assistant ns into the per-ns CNP pattern
Two issues HA had with the cluster's CiliumNetworkPolicy posture:

1. ingress-nginx-egress's allowlist enumerated apps/authentik/monitoring/
   paperless/audiobookshelf/immich/crowdsec but not home-assistant — so
   ingress-nginx pods on k8s-1/2/3 were dropped (Policy denied) when they
   tried to reach HA at home-assistant.svc.cluster.local. home.renner.uno
   was returning 504 gateway timeout because of this. Add home-assistant
   to the egress allowlist.

2. The home-assistant namespace had no per-ns CNP, unlike audiobookshelf/
   authentik/paperless. Inbound was effectively allow-all. Add
   default-deny-ingress modeled on audiobookshelf-ns.yaml, with the
   HA-specific extras: ingress from world (IoT devices reach HA via the
   Cilium-L2 LB on 10.2.30.224 with source IP preserved), and egress to
   authentik + ingress for east-west OIDC, plus world for IoT devices,
   external integrations, and the seed tarball pull from garage S3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:02:28 +02:00
k.rennerandClaude Opus 4.7 dd195a4252 fix(home-assistant): mqtt sidecar — drop legacy TLS fields from entry
The previous merge preserved certificate=auto and tls_insecure=false
left over from the legacy mqtt.renner.uno:8883 config. With the new
plain :1883 broker, HA still tried to negotiate TLS to a non-TLS
listener and got "appears to be using TLS to connect to a non-TLS
listener; protocol error" on every connect attempt — so the entry
was reading correctly but the integration was unusable.

Also treat the *presence* of certificate or tls_insecure in the entry
data as drift, so the residue gets stripped on the next reconcile and
stays gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:13:17 +02:00
k.rennerandClaude Opus 4.7 0d1ef94b9d fix(home-assistant): mqtt sidecar — sort jq keys before string-diffing
The drift check compares two jq -c outputs as strings. jq -c
preserves the input's key order, so a filtered current_mqtt has
keys in the source entry's order (broker, password, port, username)
while desired_mqtt has them in the order I wrote them (broker,
port, username, password). Identical values, different bytes,
spurious drift, perpetual self-delete loop.

Add -S to both jq invocations so keys come out sorted lexically and
the comparison reflects values only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:09:17 +02:00
k.rennerandClaude Opus 4.7 7960e6c221 fix(home-assistant): mqtt sidecar — don't reconcile HA's stripped defaults
The previous desired_mqtt set included discovery: true and
discovery_prefix: "homeassistant" — both are HA defaults, so HA strips
them on every save of core.config_entries. The sidecar then re-detected
the missing keys as drift, re-patched, self-deleted the pod. New pod
came up, HA stripped defaults again, sidecar drifted again. Pods
cycled in <1m.

Reconcile only fields HA persists: broker, port, username, password.
Defaults are HA's responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:42:46 +02:00
k.rennerandClaude Opus 4.7 2967453f6e fix(home-assistant): config-reconciler works without HAOS restore
The sidecar gated its sync loop on /config/.HA_RESTORE_RESULT, which
only appears after a HAOS-tarball restore. On a clean install or a
pre-existing /config (the actual current state — HA already has
integrations, including a stale mqtt entry pointing at VM 110), that
flag never materializes and the sidecar blocks forever waiting,
leaving trusted_proxies + MQTT + ESPHome reconciliation never running.

Switch the gate to /config/configuration.yaml — HA writes this on both
paths (post-restore and clean startup), which is what we actually
need anyway since the patch targets that file plus .storage/ entries.

Also: shallow-merge mqtt data instead of replacing it, so
HA-managed fields we don't reconcile (protocol, transport, keepalive,
certificate, tls_insecure, …) survive across patches. Drift detection
narrows to the keys we own so a one-off HA write of e.g. keepalive
doesn't loop us.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:32:12 +02:00
k.rennerandClaude Opus 4.7 2bdc7e03c5 fix(home-assistant): mosquitto shim idempotent + correct ownership
Two issues from the previous fix:

1. mosquitto_passwd -c uses O_EXCL, so it errors with "File exists"
   on the second container start (the emptyDir at /mosquitto/auth
   persists across container restarts within a pod). Add rm -f to
   keep the shim idempotent.

2. mosquitto drops privileges to user mosquitto (UID 1883) *before*
   reading the password file, so chown root:root left the file
   unreadable to the running process. Chown to 1883:1883 instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:24:00 +02:00
k.rennerandClaude Opus 4.7 b095dcda61 fix(home-assistant): mosquitto shim creates auth files mosquitto can read
The previous shim ran as root (image has no USER directive), wrote
/mosquitto/auth/passwd, chmod'd 0600, then exec'd mosquitto. Mosquitto
opens pwfile/aclfile during startup *before* dropping to user
mosquitto, so the read itself is fine — but the previous code also
left files owned root:1883 due to fsGroup setgid, and used the fragile
mosquitto_passwd -U in-place rewrite path that was failing to open
the file with "Unable to open pwfile".

Switch to the canonical mosquitto_passwd -c -b creation flow and
chown root:root so the startup privilege check stops warning and
the open succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:19:45 +02:00
k.rennerandClaude Opus 4.7 36f27fab51 feat(home-assistant): add Mosquitto broker on IoT VLAN with self-generated creds
Mosquitto runs in the home-assistant namespace, edge-pinned, exposing
plain :1883 + TLS :8883 on a fresh IoT IP (10.2.30.225). HA reaches
the broker cluster-internally; IoT devices land on the IoT IP. TLS
certificate is issued by letsencrypt-staging via DNS-01 (cloudflare),
SANs mqtt.renner.uno + mqtt.k.renner.uno.

Credentials are generated in-cluster on first deploy by a one-shot
Job (idempotent — exits 0 if the Secret already exists), so no
plaintext password ever touches git or a human. The sidecar in the
HA pod (renamed trusted-proxies → config-reconciler to reflect its
broadened scope) reads the same Secret and patches HA's mqtt
config_entry data on drift. MQTT_PASSWORD is marked optional so HA
can come up before the bootstrap Job finishes — the sidecar's MQTT
reconcile skips silently when the env is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:12:02 +02:00
k.rennerandClaude Opus 4.7 dd9be017cb feat(monitoring): port 27 legacy Grafana dashboards to K8s sidecar
Copies every dashboard from `ansible/roles/grafana-observability/files/dashboards/`
into `kubernetes/apps/monitoring/dashboards/` and wires each as a
ConfigMap for the kube-prometheus-stack Grafana sidecar. UID rewrites
follow the buckets in DASHBOARD-MIGRATION.md:

- 11 k8s-native dashboards copied with no rewrite (UID stays
  `prometheus`/`loki`/`influxdb`).
- 9 host-legacy + 3 mixed-resolved-as-legacy (blackbox, infrastructure,
  synology-nas Prom panels) had every `"uid": "prometheus"` rewritten to
  `"uid": "prometheus-legacy"` so they continue to render against LXC 227
  until the global cut-over retires VM 109.
- log-explorer and netbird-overview are pure Loki, no rewrite.
- paperless-overview and unifi-overview kept on `prometheus` (paperless
  per k8s-native intent; unifi because the K8s Prom also scrapes the same
  unpoller exporter).

Validated with `kubectl kustomize kubernetes/apps/monitoring`: 27
ConfigMaps generated, all carrying the `grafana_dashboard: "1"` label
that the sidecar watches in the `monitoring` namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:50:13 +02:00
security-bot e7999b5194 chore(image-automation): bump velero/velero-plugin-for-aws:v1.13.2
Updates managed by image-automation-controller.
2026-05-01 14:25:14 +00:00
k.rennerandClaude Opus 4.7 4eb0d78ae6 feat(image-automation): enroll velero-plugin-for-aws + document scope
Adds velero/velero-plugin-for-aws to image-automation with a tight
1.13.x range — that matches the velero server major.minor; crossing
to 1.14 should be a deliberate operator decision tied to bumping the
server itself, not an auto-update.

README: catalogues all enrolled images and explicitly lists what's
not enrolled with reasons, so the next person doesn't waste time
trying to wire up tags that can't be policy-tracked (4-component
versions, distro-codename tags, commit-SHA tags, etc.). Cnpg-barman-
plugin is in an upstream-imported manifest and intentionally left
hand-managed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:24:18 +02:00
security-bot 6a7ac17ebb chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.34.0 ghcr.io/music-assistant/server:2.8.6
Updates managed by image-automation-controller.
2026-05-01 14:14:08 +00:00
k.rennerandClaude Opus 4.7 4f6f90280c feat(image-automation): enroll 10 apps in Flux image automation
Pins images that were on `latest` / `release` / `stable` to specific
versions so Flux's image-policy can compare against them, and adds
ImageRepository + ImagePolicy + marker comments for each.

Pinned (was → now):
  - immich-server          release  → v2.7.5
  - immich-machine-learning release → v2.7.5
  - home-assistant         stable   → 2026.4.4   (calver)
  - matter-server          stable   → 8.1.0
  - zwave-js-ui            latest   → 11.16.2
  - wyoming-whisper        latest   → 3.1.0
  - wyoming-openwakeword   latest   → 2.1.0
  - wyoming-speech-to-phrase latest → 1.4.3
  - cloudflared            latest   → 2026.3.0   (calver)

Already pinned, marker added only:
  - audiobookshelf  (2.x)
  - music-assistant (2.x)
  - gatus           (5.x, v-prefix filtered)
  - paperless-ngx   (already in pilot commit)

Tag-pattern handling:
  - Plain semver: range-only ImagePolicy.
  - v-prefix (gatus): filterTags strips v before semver compares;
    written tag retains the v.
  - Calver (home-assistant, cloudflared): same 3-component compare;
    range needs a yearly bump.
  - Pre-release filter (matter-server): pattern excludes 8.x.0bN
    pre-release tags.

ImageUpdateAutomation will pick these up on its next pass and bump
any image whose policy resolves to a newer in-range tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:13:27 +02:00
k.renner 46de39f8ac fix(image-automation): bump apiVersion v1beta2 → v1 2026-05-01 16:01:14 +02:00
security-bot f9c197515d chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:2.20.15
Updates managed by image-automation-controller.
2026-05-01 13:57:22 +00:00
k.renner 0e26958d12 fix(image-automation): use .NewValue, not .ImageRef 2026-05-01 15:50:06 +02:00
k.rennerandClaude Opus 4.7 b2328af21d fix(image-automation): use .Changed in commit message template
ImageUpdateAutomation reported "template uses removed '.Updated'
field. Please use '.Changed' instead." Updating per the v1beta2
docs (https://fluxcd.io/flux/components/image/imageupdateautomations/#message-template).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:47:09 +02:00
k.rennerandClaude Opus 4.7 3ec24ef20d feat(flux): install image-reflector + image-automation controllers
The flux bootstrap was originally run without --components-extra,
so only the four core controllers (source, kustomize, helm,
notification) were installed. The image-* CRDs and Deployments
weren't in the cluster, which meant the image-automation
manifests added in 5c5d39e/b5508d0 immediately broke the
infrastructure Kustomization with "no matches for kind ImagePolicy
in version image.toolkit.fluxcd.io/v1beta2".

Verified by:
  kubectl -n flux-system get deployments
    -> 4 controllers, no image-*
  kubectl get crd | grep image.toolkit
    -> empty
  kubectl api-resources --api-group=image.toolkit.fluxcd.io
    -> empty

Two changes:

  1. Regenerated gotk-components.yaml with
     `flux install --components-extra=image-reflector-controller,
                   image-automation-controller --export`.
     Now ships all six controllers + their CRDs.

  2. bootstrap.sh: same --components-extra flag added to the
     `flux bootstrap gitea` call so the next rebuild-k8s run
     produces a cluster that includes the image controllers from
     the start, not as a one-off bolt-on.

After flux reconciles flux-system, the CRDs land, the new
Deployments come up, and the infrastructure Kustomization can
apply image-automation/ cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:58 +02:00
k.rennerandClaude Opus 4.7 b5508d0226 fix(image-automation): reuse bootstrap flux-system GitRepository
The original scaffold created a separate flux-system-write GitRepository
with its own security-bot Secret, requiring the user to manually
sops-encrypt and commit a credentials file. That's a manual bootstrap
step — exactly the "needs a human action to reach usable state"
anti-pattern.

`flux bootstrap gitea --token-auth` (kubernetes/bootstrap.sh:202)
already creates the flux-system Secret with a write-capable token.
Verified: `kubectl get secret flux-system -n flux-system -o yaml`
shows username + password fields populated.

Changes:
  - update-automation.yaml.sourceRef → flux-system (the existing GR).
  - Drop gitrepository.yaml (the redundant flux-system-write CR).
  - Drop git-secret.yaml.template and the sops bootstrap dance from
    README + kustomization.yaml.

Push identity is now the bootstrap token's owner (gitea_admin) instead
of security-bot. Commit *author* is still overridden to
security-bot@renner.uno via the spec.git.commit.author block, so PRs
and `git log` still surface the bot identity for visibility — only
the actual git push transport uses the bootstrap creds.

Net result: zero manual steps. Push this and image-automation is live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:29:15 +02:00
k.rennerandClaude Opus 4.7 5c5d39e744 feat(image-automation): scaffold Flux image-automation with paperless-ngx as pilot
Adds kubernetes/infrastructure/image-automation/ — central home for
Flux image-automation resources. The image-reflector + image-automation
controllers are already running (gotk-components.yaml ships them);
this is the first time we declare CRs that use them.

What lands:
  - flux-system-write GitRepository (separate from the bootstrap
    flux-system GR so we don't disturb its existing read-only auth)
    pointing at gitea.renner.uno/gitea_admin/homelab via a Secret
    named image-automation-git.
  - ImageUpdateAutomation "homelab" walking ./kubernetes, applying
    the Setters strategy from `# {"$imagepolicy": "..."}` markers,
    pushing direct to master as security-bot@renner.uno.
  - Pilot pair: ImageRepository + ImagePolicy "paperless-ngx" tracking
    ghcr.io/paperless-ngx/paperless-ngx within "2.x" semver. The 3.x
    cutover would need manual policy widening — by design.
  - Marker on kubernetes/apps/paperless/paperless.yaml's image line
    so the controller knows where to substitute.
  - README explaining the bootstrap step and how to onboard more
    images.

Manual one-time bootstrap REQUIRED before this works:
  1. Create kubernetes/infrastructure/image-automation/git-secret.sops.yaml
     from the template (token = security-bot Gitea PAT, write:repository
     scope — same one used by the Woodpecker security_bot_token secret
     can be reused).
  2. sops -e -i to encrypt in place.
  3. Uncomment the line in kustomization.yaml that lists it.
  4. Push. Flux's existing infrastructure Kustomization handles the
    sops-decryption.

Strategy choice (per user): direct push to master, not a PR branch.
Tight semver ranges per image are the safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:23:34 +02:00
k.rennerandClaude Opus 4.7 9f48960589 revert(security-fix-agent): drop image-tag-bump role
ci/woodpecker/push/build-ci-images Pipeline was successful
Container image tag updates are Flux's job — the homelab cluster
already runs image-reflector-controller and image-automation-
controller (in flux-system/gotk-components.yaml). Setting up
ImageRepository / ImagePolicy / ImageUpdateAutomation resources
is the right home for "bump postgres:15.17 → 15.18 when a new
image is published"; doing it from a CVE-driven scan agent
duplicates that pipeline and competes with it.

Reverting:
  - Agent prompt: drop the `## Image tag bumps` section; image
    findings now go straight to the Skipped list with a "Flux
    image-automation owns this" reason.
  - Woodpecker yml: drop Bash(crane *) from the agent's tool
    allowlist (no longer used) and the `pull: true` workaround
    that existed only to pick up crane's mid-day rebuild.
  - ci-claude Containerfile: remove the crane install. The image
    is back to Node + Claude Code + git/jq/curl basics.

The agent is now scoped exclusively to IaC misconfigurations
(Trivy fs scan results). Image vulns surface in the log + summary
for visibility but no PR action.

Note: there are currently zero ImageRepository / ImagePolicy /
ImageUpdateAutomation resources in kubernetes/ — the controllers
are running idle. Wiring them up for the actual images is a
separate workstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:09:06 +02:00
k.rennerandClaude Opus 4.7 7d1fa21904 fix(e2e-test): use CephFS RWX for velero data-movement test
Velero CSI snapshot data movement against an RWO ceph-block (RBD) PVC
fails when the source pod still has it RW-mounted on the same node:

  mount: cannot mount /dev/rbdN read-only

The krbd device can't be active in two modes simultaneously, so the
snapshot exposer pod's RO mount conflicts with the writer pod's RW
mount. Same failure mode is documented in
kubernetes/seed/music-assistant/pvcs.yaml.

Switch the test PVC to ceph-filesystem (CephFS RWX) — concurrent RO
snapshot mount + RW source mount are fine on CephFS. The test still
exercises the same Velero CSI data-movement code path end-to-end
(snapshot → upload to BSL → restore → mount).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:55:43 +02:00
k.rennerandClaude Opus 4.7 b63a8f86b8 fix(e2e-test): drop stale -db KS checks, add seed KS check
After the seed-phase refactor (3063b54), authentik-db / paperless-db
Kustomizations no longer exist — they were merged into authentik /
paperless KSs (commit ae76682), then the database resources moved
into the new seed KS. The e2e-test was still grepping for the old
names and FAILing on every run. Drop them and add the new seed KS
to the readiness check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:53:20 +02:00
k.rennerandClaude Opus 4.7 4ec7646295 fix(security-scan): force-pull ci-claude image
ci/woodpecker/manual/security-scan Pipeline was successful
Pipeline 864's agent reported crane was not installed even though
the build-claude step had baked crane into ci-claude:2026-05-01.
The wp-agents had the pre-crane image cached at the same tag —
woodpecker reuses already-pulled images by default — so the agent
was running on a stale layer set.

pull: true tells woodpecker to refresh the image on every run,
which surfaces same-day rebuilds (the date tag stays put while
contents change). Once the tag rolls over for a different reason
this can come back off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:52:35 +02:00
k.rennerandClaude Opus 4.7 b4975fabbb ci: store cluster access in Garage instead of git
ci/woodpecker/manual/e2e Pipeline failed
ci/woodpecker/manual/security-scan Pipeline was successful
The rebuild-k8s pipeline previously SOPS-encrypted talosconfig and
kubeconfig and pushed them back to master. The git push raced with
concurrent commits and forced workstation users to `git pull --rebase`
after every rebuild.

Replace the transport with Garage's k8s-secrets bucket
(s3://k8s-secrets/cluster-access/), reusing the existing secrets-key
that the install-garage-synology playbook already provisions. SOPS
encryption is unchanged — only the storage layer moves.

- New scripts/ci/fetch-kubeconfig.sh helper: 9 CI steps that previously
  inlined `sops -d kubernetes/access/kubeconfig.sops` now call this.
- scripts/import-cluster-access.sh rewritten to pull both files from
  Garage; no more git fetch/checkout.
- export-access step uploads via aws s3 cp; gitea_token + git config
  removed.
- All consuming Woodpecker steps gain garage_secrets_key_id /
  garage_secrets_secret_key from_secret env vars, and bump to
  ci-k8s:2026-05-01 (the tag with aws-cli baked in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:32:41 +02:00
k.rennerandClaude Opus 4.7 9fd42c3158 fix(security-scan): pipe agent prompt via stdin
Pipeline 861's agent step ran claude successfully but the workflow
ended in failure with `/bin/sh: syntax error: unterminated quoted
string` — emitted between claude's output and the next command
block's tracer.

Cause: the prior pattern was

  claude ... -p "$(cat ci/agents/security-fix/prompt.md)"

which substitutes a ~6 KB blob (lots of backticks, em-dashes, code
snippets, contractions) onto the command line. Under woodpecker's
busybox-ash invocation with `set -ex`, the tracer mishandled the
substituted argument and aborted the script — even though the
underlying claude call already finished.

Fix: pipe the prompt file via stdin instead. claude `-p` reads
the prompt from stdin when no positional argument is given (verified
locally against the prepared image — STDIN_OK round-trip).

Other fallout: removed em-dashes and backticks from the surrounding
shell comments to keep the trace output unambiguous, and added a
note explaining why stdin is used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:22:10 +02:00
k.rennerandClaude Opus 4.7 cf98c3f044 fix(namespaces): move influxdb namespace into infrastructure/namespaces
ci/woodpecker/manual/security-scan Pipeline failed
ci/woodpecker/cron/security-scan Pipeline failed
ci/woodpecker/cron/e2e Pipeline failed
The seed KS owns influxdb-seed-ssh Secret in the influxdb namespace, but
the namespace was previously created by apps/influxdb/namespace.yaml —
which dependsOn seed → seed never reconciles → circular.

Hoist the namespace into infrastructure/namespaces/namespaces.yaml
(consistent with all the other app namespaces) and drop it from the
apps/influxdb Kustomization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:48:06 +02:00
k.rennerandClaude Opus 4.7 fc1911fe27 ci(ci-claude): add crane CLI for registry tag queries
ci/woodpecker/push/build-ci-images Pipeline was successful
Pipeline 858's agent run found 277 image vulns (CRITICAL/HIGH) and
correctly skipped all of them — but the right action for many is a
patch-level image-tag bump (postgres:15.17 → 15.18,
distribution:3.0.0 → 3.0.1, etc.). The agent had no way to verify
a candidate tag exists in the registry, so it conservatively
skipped.

crane (google/go-containerregistry) handles:
  crane ls <repository>           — list available tags
  crane manifest <image>:<tag>    — verify tag resolves

Single static binary, works against Docker Hub, GHCR, gitea, quay,
any OCI registry. Pinned to v0.21.5.

The agent prompt update (only-patch bumps, lockstep cross-file edits,
compatibility-caveat surfacing) and the woodpecker allowlist update
(Bash(crane ls *), Bash(crane manifest *)) ride in a follow-up
commit so this image change can build first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:34:00 +02:00
rebuild-k8s pipeline 0f0d99c90f ci: Export cluster access files from rebuild 2026-05-01 11:29:10 +00:00
k.rennerandClaude Opus 4.7 3063b54ab4 feat(seed): dedicated migration phase between infra and apps
ci/woodpecker/manual/rebuild-k8s Pipeline failed
Replaces the per-app race-y CNPG bootstrap.recovery + velero-bootstrap-restore
pattern with a single seed Flux KS that populates databases and PVCs from
legacy hosts before any app KS reconciles.

Changes:

- New kubernetes/seed/<app>/ tree owning, per app:
  - CNPG Cluster CRs with bootstrap.initdb (was: bootstrap.recovery) +
    enableSuperuserAccess so seed Jobs can connect as postgres
  - DB seed Jobs: ssh legacy 'pg_dump -Fc' | pg_restore -d <db> against
    the running cluster (was: ephemeral PG + barman-cloud-backup → S3)
  - PVC seed Jobs: rsync/ssh+tar from legacy → app PVCs as standalone
    Jobs (was: in-pod init containers, blocking the app Deployment's
    progressDeadlineSeconds for hours on a 250 GiB rsync)
  - All ObjectStores, ScheduledBackups, barman-creds, PVCs, seed-ssh
    Secrets relocated here

- New top-level kubernetes/clusters/homelab/seed.yaml Flux KS:
  dependsOn infrastructure-cnpg-barman, timeout 4h, healthCheckExprs
  cover CNPG Cluster phase + native Job succeeded.

- Stripped from kubernetes/apps/<app>/: Cluster CRs, seed Jobs, init
  container rsync blocks, ssh volumes, seed ServiceAccount references.
  ~454 lines deleted, ~33 added.

- App KSs (immich, authentik, paperless, audiobookshelf,
  music-assistant, influxdb): dependsOn velero-bootstrap-restore →
  dependsOn seed.

- bootstrap.sh wait-apps: new wait_kustomization seed (4h) before
  per-app waits; per-app timeouts dropped to 60s since they're
  near-instant once seed is Ready.

- Deleted kubernetes/clusters/homelab/velero-bootstrap-restore.yaml;
  manifests stay in kubernetes/infrastructure/velero-bootstrap-restore/
  for post-cutover re-add (it remains the cold-rebuild path once
  legacy hosts are decommissioned and seed phase is removed).

Eliminates the documented race failure modes:
- cnpg-bootstrap-recovery-no-initdb-fallback (recovery Job pounds empty
  bucket until seed lands → multiple Error pods)
- velero-bootstrap-restore-bsl-sync-race (Job iterates schedules in an
  empty bucket for ~15 min)
- immich-server progressDeadlineSeconds blowup (250 GiB rsync init
  exceeds the 600s default and Flux marks the Deployment Failed)

Seed is migration-only: removed entirely once all services have cut
over from legacy. At that point the Cluster + ObjectStore +
ScheduledBackup + PVC manifests move back into kubernetes/apps/<app>/
and velero-bootstrap-restore.yaml is re-added at the cluster level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:21:44 +02:00
k.rennerandClaude Opus 4.7 671eada6de fix(security-fix-agent): explicit working-directory hint
ci/woodpecker/manual/security-scan Pipeline was successful
Pipeline 856's agent run found the Trivy JSON files via the
pipeline's own jq summary (IaC=0, Image=645 findings) but the
agent itself reported "No Trivy scan files (/tmp/trivy-iac.json
...) were present" — it hallucinated the /tmp prefix and didn't
look in the workspace root.

Prompt now states explicitly that the JSONs are in the current
working directory (project root, not /tmp), and instructs the
agent's first action to be `Glob trivy-*.json` to enumerate what
exists. Adds a stop-condition for the genuinely-empty case so
the agent can't get into a "I'll assume they're somewhere else"
loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:20:04 +02:00
rebuild-k8s pipeline e2cb1e686c ci: Export cluster access files from rebuild 2026-05-01 09:56:28 +00:00
k.rennerandClaude Opus 4.7 760317ab6e revert(security-scan): agent gates on success only
ci/woodpecker/manual/security-scan Pipeline was successful
ci/woodpecker/manual/rebuild-k8s Pipeline failed
Earlier I added `status: [success, failure]` to the agent's `when`
under the assumption that image-scans's `failure: ignore` wouldn't
prevent downstream skipping. That was correct in mechanics but
wrong in intent: the agent SHOULD only run when the scans actually
completed cleanly. If iac-scan or clone fails, the agent has nothing
trustworthy to act on — better to surface the workflow failure
than open a PR based on partial/missing JSON.

`failure: ignore` on image-scans is kept (a single bad image
shouldn't block the run); other failure modes correctly cascade
to skip the agent.

Pipeline 853's "skipped agent" outcome was actually a Woodpecker
glitch (clone retroactively flipped to failure mid-run) and not a
design problem — once that glitch passes, default success-only
gating is the right behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:31:04 +02:00
k.rennerandClaude Opus 4.7 2d3cd7a819 fix(security-scan): unblock agent step + harden trivy image scans
ci/woodpecker/manual/rebuild-k8s Pipeline failed
ci/woodpecker/manual/security-scan Pipeline failed
Pipeline 850 reproduced two distinct failures:

1. image-scans died — Apache Tika hit Trivy's default 5-minute
   `context deadline exceeded`, and goauthentik/server's 30MB
   msgraph kiota-dom-export.txt blew the secret scanner's memory
   budget (Trivy itself warned about it).

2. security-fix-agent was skipped despite `failure: ignore` on
   image-scans. Woodpecker's `failure: ignore` only affects
   *workflow* status — downstream steps still gate on the
   previous step's exit, defaulting to status: [success].

Fixes:

- `--timeout 15m` and `--scanners vuln` on per-image trivy calls.
  We only care about CVEs (CRITICAL/HIGH, with FixedVersion) — the
  secret scanner was the OOM culprit and adds no value for our
  agent's decision rules.
- `when: status: [success, failure]` on security-fix-agent so it
  always runs, regardless of which scans crashed. The step's own
  gating logic already handles missing/empty JSON files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:45:53 +02:00