Compare commits

...
Author SHA1 Message Date
atlas-botandClaude Opus 5.5 0d1181abb1 paperclip: per-agent kubectl ServiceAccounts + apiserver egress CNP (REN-414)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Five ServiceAccounts agent-{atlas,iris,hestia,argus,hermes} in paperclip
with empty service-account-token Secrets. The four readers get the same
rights they hold today via the shared pod SA: `view` cluster-wide and the
existing monitoring/paperclip-prometheus-read Role. Hermes and the shared
SA get `get` on the five named token Secrets only.

CiliumNetworkPolicy paperclip-renner/paperclip-agents-kube-apiserver lets
the five agents' pods reach the kube-apiserver entity on 6443.

Spec: REN-412. Nothing restarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:14:52 +00:00
argus-bot (Paperclip service account) cf77302899 Merge pull request 'network-policies: agent pods in paperclip-renner reach Gitea via Traefik (REN-411)' (#1079) from atlas/ren-411-agent-gitea-egress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-30 22:58:00 +00:00
atlas-botandClaude Opus 5.5 e8ba85ca76 network-policies: let paperclip-renner agent pods reach Traefik on 8443 (REN-411)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
gitea.renner.uno resolves to the Traefik LB VIP; Cilium DNATs it to a Traefik
pod before egress policy, so the plugin's toFQDNs allow never matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:56:03 +00:00
security-bot 3fffd5a4ef chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1790806287@sha256:f0b4b4e09cc02fbc472faedf3edb6d39339ade7e2932c6a435f601209b92d646
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 22:35:10 +00:00
argus-bot (Paperclip service account) 5062b09214 Merge pull request 'paperclip: apply upstream PR #14769 to the image (REN-409)' (#1078) from atlas/ren-409-paperclip-14769 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-30 22:10:51 +00:00
atlas-botandClaude Opus 5.5 c165f3d687 paperclip: apply upstream PR #14769 to the image (REN-409)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adapter environment tests sync managed config into the pod before any
workspace is realized, so the lease has no remoteCwd and the Claude test
fails with claude_managed_config_dir_failed. #13733 fixes only the run
path. #14769 (open upstream, head 10dc7371) makes the Kubernetes plugin
set remoteCwd=/workspace on acquire and keep a recorded one on resume.
The plugin build from REN-405 is unchanged, so dist/ carries the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:09:04 +00:00
security-bot d30c2aa498 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1790803664@sha256:e04780cf178e99195da4a6796b0f97d718ca27c332769350e1bfc875c722e75d
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 21:45:10 +00:00
argus-bot (Paperclip service account) 571a87746a Merge pull request 'paperclip: build the Kubernetes sandbox plugin into the image (REN-405)' (#1077) from atlas/ren-405-k8s-plugin-dist into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-30 21:27:19 +00:00
atlas-botandClaude Opus 5.5 8aa432c6ec paperclip: build the Kubernetes sandbox plugin into the image (REN-405)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Upstream's production target ships the plugin source without dist/, so the
plugin loader reports paperclip.kubernetes-sandbox-provider as "no longer
exposes a Paperclip manifest". prepare.sh now adds upstream's cloud-plugins
commands for kubernetes to the build stage, after the server build, and fails
the build if dist/manifest.js is missing. The #13733 patch is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:23:40 +00:00
security-bot f6981ffdbe chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1790800098@sha256:381fac2f844114a87a537095705318524f22a8cbfa31558c66fdd49527c0e9bd
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 20:45:09 +00:00
argus-bot (Paperclip service account) e2ad903f02 Merge pull request 'paperclip build: kaniko --compressed-caching=false after OOM kill (REN-401)' (#1076) from atlas/ren-401-kaniko-oom into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-30 20:27:55 +00:00
atlas-botandClaude Opus 5.5 aed1ab5d75 paperclip build: kaniko --compressed-caching=false after OOM kill (REN-401)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Pipeline 11717 build-paperclip exited 137 at the 8Gi memory limit during
the server build. Disable compressed caching so kaniko does not keep
compressed layer tarballs in memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:25:11 +00:00
argus-bot (Paperclip service account) 01451a7eb3 Merge pull request 'paperclip: build upstream a027f76 + PR #13733, track it with Flux (REN-401)' (#1075) from atlas/ren-401-paperclip-patched-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline failed
2026-09-30 20:03:55 +00:00
atlas-botandClaude Opus 5.5 864d104d0c paperclip: build upstream a027f76 + PR #13733 and track it (REN-401)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
k8s sandbox runs fail because the run path never sets the lease remoteCwd
(paperclipai/paperclip#13864). The fix, paperclipai/paperclip#13733, is not
merged upstream yet. Build our own image from the master commit the nightly
runs now plus that patch, and point Flux image automation at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:59:22 +00:00
security-bot 07dc3ebea1 chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.76.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 18:40:44 +00:00
security-bot 96cfe20249 chore(image-automation): bump ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.15.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 14:40:41 +00:00
security-bot ba099fccbe chore(image-automation): bump ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.15.1
Updates managed by image-automation-controller.
2026-09-30 14:40:03 +00:00
security-bot feef462935 chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:c035c731e082533ae3a5a67ddafbe7636619a6148de6bce36c67cc842ac84ea2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-30 10:27:01 +00:00
argus-bot (Paperclip service account) 92ba011aca Merge pull request 'navidrome: beets writes the retried partial-album matches (REN-350)' (#1073) from atlas/ren-350-beets-write-retry into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-30 03:04:40 +00:00
atlas-botandClaude Opus 5.5 d95a5e9137 navidrome: beets writes the retried partial-album matches, drops one wrong match (REN-350)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 03:02:24 +00:00
argus-bot (Paperclip service account) 635bd6a02c Merge pull request 'chore(home-assistant): drop unused backup-password ExternalSecret, fix stale seed comment (REN-339)' (#1062) from atlas/ren-339-ha-backup-cleanup into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
2026-09-30 00:06:06 +00:00
security-bot 507c807925 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.37.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-29 23:40:31 +00:00
k.renner 37f2bd0718 Merge pull request 'fix(navidrome): let non-browser clients reach the native API' (#1072) from fix/navidrome-native-api into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1072
2026-09-29 13:42:16 +00:00
claude-botandClaude Opus 5.5 1184a552fb fix(navidrome): let non-browser clients reach the native API
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Koda (Garmin Connect IQ) calls POST /auth/login during sync. Only /rest
and /share bypassed the navidrome-gate, so the gate answered 302 with an
empty body and the watch failed with -400
INVALID_HTTP_BODY_IN_NETWORK_RESPONSE ("Native login failed -400").

An IngressRoute sends /auth and /api straight to Navidrome when the
request carries no authentik_proxy_ session cookie; requests with the
cookie (the web UI) keep going through the gate, so SSO is unchanged.
X-Authentik-Username is stripped on this route as on /rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:41:36 +02:00
security-bot f53d41d3c9 chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:c02c7a8385fe5e3e9999ec03ecb45f29d60b8bf44dac3fedf032d7ef2046045d
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-09-29 10:26:36 +00:00
argus-bot (Paperclip service account) 569f8e0b4c Merge pull request 'network-policies: paperclip agents may reach the Lidarr API on 8686 (REN-350)' (#1071) from atlas/ren-350-paperclip-lidarr-cnp into master 2026-09-29 10:25:08 +00:00
atlas-botandClaude Opus 5.5 9b72377b9c network-policies: let paperclip agents reach the Lidarr API on 8686 (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner-approved on REN-350. Only the Lidarr pod, only 8686; nothing else in
navidrome is admitted. Lidarr uses AUTH__METHOD External, so this gives
agents in the paperclip pod full Lidarr access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:22:48 +00:00
argus-bot (Paperclip service account) 0ed9565553 Merge pull request 'navidrome: beets retries skipped albums (partial-album match trial, no writes) (REN-350)' (#1070) from atlas/ren-350-beets-retry-skips into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-29 10:04:01 +00:00
atlas-botandClaude Opus 5.5 bd300ffcfa navidrome: beets trial lists re-matched albums whose release changed (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review 1258: the 74 written albums are re-imported, not duplicate-skipped.
Fix the comment and report before/after mb_albumid per album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:03:07 +00:00
atlas-botandClaude Opus 5.5 bb69b196e4 navidrome: beets retries skipped albums, partial-album match trial with -W (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:00:32 +00:00
argus-bot (Paperclip service account) 9ab8bc9735 Merge pull request 'navidrome: beets writes tags (write: yes) (REN-350)' (#1069) from atlas/ren-350-beets-write into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-29 07:21:32 +00:00
atlas-botandClaude Opus 5.5 af0c0829af navidrome: beets writes tags (write: yes, beet write) (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Two dry runs were checked; the spot check now matches original releases.
Music mount becomes writable for tag writes; move/copy/delete stay off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:17:34 +00:00
argus-bot (Paperclip service account) b670c94bf0 Merge pull request 'chore(ci): bump ci-claude image to 2.1.283' (#1057) from ci-image/claude-2.1.283 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-29 05:17:01 +00:00
argus-bot (Paperclip service account) f91f2dc08b Merge pull request 'navidrome: beets prefers original releases, reset dry-run DB once (REN-350)' (#1068) from atlas/ren-350-beets-phase2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-29 04:22:18 +00:00
atlas-botandClaude Opus 5.5 283415e689 navidrome: beets prefers original releases, reset dry-run DB once (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The first dry run (write: no) matched 78 of 560 albums; some matched
reissues (JP/RU) and would have changed year to the reissue year.
Set original_date and preferred countries/media/original_year, and
drop the dry-run beets DB once so the next run re-matches. Still write: no.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 04:21:16 +00:00
security-bot 0bf8c43970 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.37.0
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-28 23:40:15 +00:00
security-bot 1cced943c1 chore(image-automation): bump ghcr.io/usetrmnl/trmnl-ha-amd64:0.11.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-28 20:40:13 +00:00
argus-bot (Paperclip service account) 81418f5b36 Merge pull request 'navidrome: add beets CronJob, tags to DB only (REN-350)' (#1067) from atlas/ren-350-beets-cronjob into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 18:35:11 +00:00
atlas-botandClaude Opus 5.5 425fe260e9 navidrome/beets: mount music library read-only (review 1248)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Phase 1 runs with write: no; beets writes only to /config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:31:51 +00:00
atlas-botandClaude Opus 5.5 7a1f9de069 navidrome: add beets CronJob, tags to DB only (REN-350)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nightly beets import over navidrome-music-bulk, in place: no move, copy,
rename or delete. First phase is write: no with fetchart/embedart off;
the job log prints matched/skipped counts and a beet write -p preview.
Renovate rule for the LinuxServer -ls<n> tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:29:08 +00:00
security-bot 7294a610a4 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.4
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-28 17:49:10 +00:00
security-bot 9136f01156 chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.4
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-28 17:42:06 +00:00
argus-bot (Paperclip service account) 45c252c15e Merge pull request 'chore(ha-restore-test): tear down the HA backup restore test (REN-339)' (#1066) from atlas/ren-339-restore-test-teardown into master
ci/woodpecker/cron/issue-fix Pipeline was successful
2026-09-28 17:36:04 +00:00
atlas-botandClaude Opus 5.5 788896902b chore(ha-restore-test): tear down the HA backup restore test (REN-339)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
C.1 (CNPG ha-pg) and C.2 (Velero /config) passed; results are on REN-339.
Removing the Flux Kustomization lets prune delete namespace ha-restore-test
with the recovered cluster, the Jobs, the Velero Restore and the restored PVC.
Also drops ha-restore-test from the OpenBao ClusterSecretStore allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:34:34 +00:00
argus-bot (Paperclip service account) 347b7e0806 Merge pull request 'fix(ha-restore-test): recreate recovered cluster as ha-pg-restore-2 (REN-339)' (#1065) from atlas/ren-339-restore-roll into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 17:26:25 +00:00
atlas-botandClaude Opus 5.5 b86ee49c98 fix(ha-restore-test): recreate recovered cluster as ha-pg-restore-2 so it starts with the plugin sidecar (REN-339)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The operator will not roll ha-pg-restore-1 after #1063; it waits on an
in-place restart the stopped instance manager cannot answer. A new name
makes Flux prune the old Cluster (pod + ephemeral PVC) and create a fresh
one. The check Job is renamed too (Job templates are immutable).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:25:20 +00:00
argus-bot (Paperclip service account) 14b248b1a4 Merge pull request 'fix(ha-restore-test): load barman-cloud plugin without WAL archiver so restored PG starts (REN-339)' (#1063) from atlas/ren-339-restore-plugin into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 17:04:07 +00:00
atlas-botandClaude Opus 5.5 e2705ba0fb fix(ha-restore-test): load barman-cloud plugin (no WAL archiver) so the restored instance starts (REN-339)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The instance manager refuses to start PostgreSQL when the Cluster references
a plugin (externalClusters) that has no sidecar in the pod. Add a plugins
entry with isWALArchiver: false and a distinct serverName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:02:38 +00:00
argus-bot (Paperclip service account) 8f9d57aade Merge pull request 'feat(ha-restore-test): throwaway HA backup restore test outside production (REN-339)' (#1061) from atlas/ren-339-ha-restore-test into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 16:41:31 +00:00
atlas-botandClaude Opus 5.5 4c4f69c4ec chore(home-assistant): drop unused backup-password ExternalSecret, fix stale seed comment (REN-339)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The seed initContainer was removed in 21a0a43f; Velero home-assistant-daily is
now the only /config restore path. home-assistant-backup-password has had no
consumer since then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:38:25 +00:00
atlas-botandClaude Opus 5.5 5a4f7efd5c feat(ha-restore-test): throwaway HA backup restore test outside production (REN-339)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Recovers ha-pg from Barman into ha-pg-restore (no WAL archiver) and restores
the home-assistant-daily Velero backup into namespace ha-restore-test. Two
Jobs log the runbook C.1/C.2 checks. The /config probe is deny-all and never
runs Home Assistant. Nothing in namespace home-assistant changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:37:43 +00:00
security-bot 36f54b61b6 chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:50f66b31fa08fe5dee20d9130e4882852c6f1a8f29eda40bfb6ce026202b2da3
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-09-28 10:26:14 +00:00
argus-bot (Paperclip service account) bab09d5f6c Merge pull request 'fix(renovate): let containerbase install tools, run with group 0 (REN-315)' (#1059) from atlas/ren-315-renovate-tool-install into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 05:30:30 +00:00
argus-bot (Paperclip service account) 3302f6c6b6 Merge pull request 'fix(renovate): no digest pins for our images; split the weekly group (REN-315)' (#1058) from atlas/ren-315-renovate-groups into master 2026-09-28 05:30:25 +00:00
atlas-botandClaude Opus 5.5 836dbb3f92 fix(renovate): no digest pins for our images; split the weekly group (REN-315)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
- gitea.renner.uno/**: pinDigests false. Our images are pinned by dated
  tag; registry GC deletes their digests (no-digest-pins, homelab #1053).
- The weekly non-major group becomes: one PR each for gitea, authentik,
  influxdb, openbao and multus/CNI (migrations or CNI, a revert does not
  undo them); one for kubernetes/apps/home-assistant/** (HA-impact review,
  02:00-05:00 window); one CI-only for .woodpecker/** and ci/images/**.
  Everything else stays in "all non-major dependencies".
- openbao chart: rangeStrategy bump. The default rewrote the bounded range
  ">=0.28.4 <0.29.0" to "<0.30.0" (homelab #1054) and dropped the lower
  bound; bump gives ">=0.29.6 <0.30.0".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:20:10 +00:00
atlas-botandClaude Opus 5.5 b0aa6dee15 fix(renovate): let containerbase install tools, run with group 0 (REN-315)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The slim Renovate image installs tools (golang for go.sum, helm) at run
time into /opt/containerbase, which it ships as root:root 0775 for its
user ubuntu (uid 12021, gid 0). The job ran with gid 12021 and a
read-only root, so every install failed ("Install tool golang failed",
ical-proxy #12) and go.sum was never updated.

Run with gid 0 and a writable root file system. The user stays
non-root, capabilities stay dropped, no API token is mounted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:07:31 +00:00
claude-bot 541094b41d chore(ci): bump ci-claude image to 2.1.283
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-28 05:04:57 +00:00
argus-bot (Paperclip service account) 8b4661fb45 Merge pull request 'chore(deps): update node.js to v24' (#1055) from renovate/node-24.x into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 05:03:22 +00:00
renovate-bot 518d6768c4 chore(deps): update node.js to v24
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-28 02:01:45 +00:00
argus-bot (Paperclip service account) 352cc1a13f Merge pull request 'fix(monitoring): Prometheus retention 30d/40GiB + alert when retention is not in force (REN-107)' (#1001) from atlas/ren-107-retention-divergence-alert into master
ci/woodpecker/cron/wiki-maintenance Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
2026-09-28 00:24:46 +00:00
atlas-botandClaude Opus 5.5 cf4da18e83 fix(monitoring): Prometheus retention 30d/40GiB, alert against cap in force, promtool suite (REN-107)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner decision on REN-107: option B, 30 days on the existing disk.
retention 90d -> 30d, retentionSize 30GB -> 40GiB, PVC unchanged (50Gi).
At ~1 GB/day the 30GiB cap bound first and the real window was a
20.5-27.2 day saw-tooth; 30d peaks near 38 GB, under the 40GiB backstop.

PrometheusRetentionNotInForce now divides the 7d store peak by the cap in
force NOW, not the cap at the time of the peak, so it clears on the day
the fix lands instead of firing for up to 7 days on the old cap's peak.

Adds tests/prometheus-retention_test.yaml (5 scenarios) and 5
`retention-*` mutations, all caught.

Restarts Prometheus once (StatefulSet args change): merge 02:00-05:00
Europe/Berlin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:21:20 +00:00
3fabf60910 feat(monitoring): alert when Prometheus retention is configured but not in force (REN-107)
`retention: 90d` and `retentionSize: 30GB` are both set on kube-prometheus-stack.
Prometheus honours whichever binds first, and here it is the size cap — so the
time setting is decoration. Measured on the live TSDB through the read-only
proxy grant, 2026-09-26:

    storage.tsdb.retention.size = 30GiB  -> 32,212,254,720 bytes
    storage.tsdb.retention.time = 90d    -> 7,776,000 s
    window actually held                 -> 2,291,239 s (26.5 days, 29% of 90d)

Size eviction is not pending — it has been running on a ~7-day cycle for at
least as long as the TSDB keeps history. At 2026-09-19 23:00->23:30 the store
went 28.16 GB -> 21.14 GB, `prometheus_tsdb_size_retentions_total` went 0 -> 1,
and the window held collapsed 27.21 -> 20.48 days. Effective retention is a
20.5-27.2 day saw-tooth. Nothing reported any of it: none of the 182 alert
rules already loaded touch retention.

REN-107 was filed on a reading of `prometheus_tsdb_size_retentions_total == 0`,
concluding the cap was ~3 days away from binding. That counter is per-process
and resets on restart; this Prometheus has had seven distinct pod IPs in 26
days, and the reading was taken on a pod started 5 days earlier. So this rule
does not read it — it compares the window on disk against the limits the
running process reports, which are true regardless of process age.

No hardcoded 90d or 30GB: both sides come from
`prometheus_tsdb_retention_limit_seconds` / `_bytes`, so editing the HelmRelease
cannot leave this rule stale.

Two clauses. The window is <90% of configured time retention, AND the store
reached 80% of the size cap within the last 7 days. The second is a guard, not
decoration: clause 1 alone fires on a legitimately young TSDB on a fresh PVC.
`max_over_time(...[7d:1h])` spans a full saw-tooth so the alert does not resolve
and re-fire twice a week on an unchanging condition, and the expression is fully
aggregated so that lookback survives the pod restarts above.

Verified against the live TSDB, not just linted — the expression as js-yaml
parses it out of this manifest returns one series with $value 2,291,414 s
(26.52 days). Counterfactuals also run live: it goes silent for a 100GiB cap
(90d intent), for `retention: 21d`, for 30d+40GiB, and for a short window on a
store far from its cap (the fresh-PVC false positive the guard exists to stop).

Scope is the alert only. This does NOT change `retention`, `retentionSize` or
the PVC — which number is the real intent is an owner call on storage cost vs
history, and changing either restarts Prometheus and gaps every series. That
decision is on REN-107 for Zeus; the config change is a separate PR.

Blast radius: a PrometheusRule is additive and loaded by the operator's config
reloader. No workload restarts, no Prometheus restart, no scrape or ingest
change, no existing rule touched, no ingress or DNS change. Worst case is a
malformed rule, which surfaces as PrometheusRuleFailures — already alerted —
and affects only this rule's evaluation.

The alert fires immediately on merge, because the condition is true today. It
clears within one evaluation once the retention config is made honest.

Revert: revert this one commit. The rule is deleted and nothing else changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 00:21:08 +00:00
argus-bot (Paperclip service account) b69c7f8985 Merge pull request 'chore(monitoring-rules): remove temporary REN-28 negative-test probes (REN-159)' (#1052) from atlas/ren-159-remove-ren28-negative-probes into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-28 00:13:08 +00:00
atlas-botandClaude Opus 5.5 725d4a5204 chore(monitoring-rules): remove temporary REN-28 negative-test probes (REN-159)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The blackbox-ren28-negative Probe existed only to take the REN-140 §7
before/after pair across REN-28 PR 2b (#1044). The "after" reading was
taken at merge (27b505e6, 2026-09-28 00:09-00:11Z): all four targets
probe_success 0 at probe_duration_seconds ~5 s, collateral still 1.

Delete the Probe, its kustomization entry, and the now-dangling mention
of the job in the CNPNegativeControlReachable comment. gotenberg:3000
stays covered by the permanent blackbox-negative-control job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:11:57 +00:00
argus-bot (Paperclip service account) 27b505e6ba Merge pull request 'feat(network-policies): scope paperless database/monitoring ingress per workload (REN-28 PR 2b)' (#1044) from atlas/ren-148-paperless-ingress-scoping into master 2026-09-28 00:06:19 +00:00
argus-bot (Paperclip service account) 2a34573823 Merge pull request 'feat(ci): claude-agent-acp in the agent-runtime images (REN-302)' (#1051) from atlas/ren-302-claude-agent-acp into master
ci/woodpecker/push/build-agent-runtime Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 21:41:31 +00:00
atlas-botandClaude Opus 5.5 ed6903ae88 fix(ci): claude-agent-acp with Paperclip's SDK override (REN-302)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
npm install -g ignored Paperclip's pnpm override and pulled
claude-agent-sdk 0.3.257. Install from a package.json with the override
(0.3.280) into /opt/claude-agent-acp and link the bin into /usr/local/bin.
The build fails if the SDK version is not 0.3.280.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:40:25 +00:00
atlas-botandClaude Opus 5.5 9049a6b241 feat(ci): claude-agent-acp in the agent-runtime images (REN-302)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The k8s-go adapter test failed with 'Claude ACP server command is not
available: claude-agent-acp'. Both agent-runtime images now install
@agentclientprotocol/claude-agent-acp@0.73.0, the version Paperclip pins,
with Paperclip's patch for it applied (vendored in agent-runtime-common/).

go and gofmt are also linked into /usr/local/bin, so every tool is found
with the default PATH, not only with the image ENV. The build checks that
with env -i. Kaniko context is now ci/images so both images can COPY the
shared patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:33:12 +00:00
argus-bot (Paperclip service account) 780adadddc Merge pull request 'fix(agent-sandbox): pin v0.5.6 for Sandbox v1alpha1 (REN-298)' (#1050) from atlas/ren-298-agent-sandbox-v0.5.6 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 21:16:17 +00:00
atlas-botandClaude Opus 5.5 38b7589c8d fix(agent-sandbox): pin v0.5.6 for Sandbox v1alpha1 (REN-298)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Paperclip Kubernetes sandbox plugin (0.1.0-alpha.1) hardcodes
agents.x-k8s.io/v1alpha1; v1.0.x serves only v1beta1, so the k8s-go
probe gets 404. v0.5.6 serves v1alpha1 (webhook conversion) and stores
v1beta1, the same storage version as v1.0.4.

- GitRepository tag and controller image -> v0.5.6 (digest pinned).
- Deployment: emptyDir for the self-signed webhook cert (read-only root).
- CiliumNetworkPolicy: allow kube-apiserver ingress (conversion webhook).
- Renovate: hold agent-sandbox below 1.0.0 until the plugin moves to v1beta1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:14:10 +00:00
argus-bot (Paperclip service account) 2b9e599573 Merge pull request 'fix(ical-proxy): roll out main-926ead0 (2 MiB upstream body cap, REN-294)' (#1049) from atlas/ren-294-ical-proxy-926ead0 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 20:47:07 +00:00
atlas-botandClaude Opus 5.5 1cd4b864ef fix(ical-proxy): roll out main-926ead0 (2 MiB upstream body cap)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
main-926ead0 includes ical-proxy PR #7 (REN-230): the upstream response
body is capped at 2 MiB, so an oversized feed gets 413 instead of being
buffered whole into a 256Mi pod. It also includes PR #10: upstream fetch
failures are logged with the host and answered with 502 instead of 500.

Refs: REN-294

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:45:38 +00:00
argus-bot (Paperclip service account) 56deab1e65 Merge pull request 'fix(ci): agent-runtime images in their own workflow (REN-286)' (#1048) from atlas/ren-286-agent-runtime-own-workflow into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-agent-runtime Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 20:30:18 +00:00
atlas-botandClaude Opus 5.5 93778121f1 fix(ci): agent-runtime images in their own workflow (REN-286)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
On the #1046 merge (pipeline 11306) the build-agent-runtime step got no
pod: the full rebuild of build-ci-images.yml had filled the 24-CPU
woodpecker-ci-quota ("exceeded quota"), so the go image was never built.

Move the step to .woodpecker/build-agent-runtime.yml. It runs only when
ci/images/agent-runtime-*/** or its own file change, and can be re-run
alone with a deployment to target build-agent-runtime. Step content is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:26:17 +00:00
argus-bot (Paperclip service account) c7ebefdbec Merge pull request 'fix(agent-sandbox): drop invalid chart labels via postRenderer (REN-285)' (#1047) from atlas/ren-285-agent-sandbox-labels into master 2026-09-27 20:24:07 +00:00
argus-bot (Paperclip service account) aaadaf0d2f Merge pull request 'feat(ci): agent-runtime go and ops images for the k8s sandbox (REN-286)' (#1046) from atlas/ren-286-agent-runtime-images into master
ci/woodpecker/push/build-ci-images Pipeline failed
2026-09-27 20:22:57 +00:00
atlas-botandClaude Opus 5.5 8b2910674c fix(agent-sandbox): drop invalid chart labels via postRenderer (REN-285)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
HelmRelease agent-sandbox is Stalled (InstallFailed): the chart's common
labels render app.kubernetes.io/version from image.tag (contains the
digest, >63 chars) and helm.sh/chart with Flux's +<rev> suffix. Remove
both labels in a kustomize postRenderer. Selector and pod labels are
unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:21:21 +00:00
atlas-botandClaude Opus 5.5 a1ac4abe80 fix(ci): agent-runtime in one 1-CPU step; image builds on master only (REN-286)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The first push of this branch started all 13 build steps: the push trigger
had no branch filter. 13 pods at the 2-CPU default limit exceeded the
24-CPU woodpecker-ci-quota, so the PR checks and one build failed.

- push trigger: branch master only
- go and ops build in one step, one pod, at 1 CPU: a full rebuild is 23 CPU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:13:30 +00:00
atlas-botandClaude Opus 5.5 ee11744c08 feat(ci): agent-runtime go and ops images for the k8s sandbox (REN-286)
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline failed
ci/woodpecker/push/build-ci-images Pipeline failed
Two pod images for the Paperclip Kubernetes sandbox (REN-284 step 2):
the stock agent-runtime-claude, pinned by digest, plus
- agent-runtime-go: Go 1.27.1, golangci-lint v2.14.0
- agent-runtime-ops: kubectl v1.36.5, flux v2.9.5, kustomize v5.8.1, helm 4.3.0

Tools are copied from their upstream images pinned by tag and digest, so
the existing Renovate dockerfile manager bumps them. Pushed to
konairius/agent-runtime-{go,ops}/agent-runtime-claude:v1 because the plugin
keeps that name and swaps only the registry prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:09:29 +00:00
argus-bot (Paperclip service account) 80d9798132 Merge pull request 'feat(paperclip): install agent-sandbox, grant plugin rights on paperclip-renner (REN-285)' (#1045) from atlas/ren-285-agent-sandbox into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 20:02:48 +00:00
atlas-botandClaude Opus 5.5 7a9aa809f1 feat(paperclip): install agent-sandbox, grant plugin rights on paperclip-renner (REN-285)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
- infrastructure/agent-sandbox: Flux GitRepository (tag v1.0.4) + HelmRelease
  of the upstream in-repo chart; image pinned by digest, restricted
  securityContext, limits. CRDs via crds: CreateReplace.
- namespaces: agent-sandbox-system and paperclip-renner, both PSS restricted.
- network-policies: CNP for agent-sandbox-system (probes in, apiserver out).
- apps/paperclip/sandbox-rbac.yaml: Role + RoleBinding in paperclip-renner and
  a create-namespaces ClusterRole, bound to paperclip-prometheus-read (the SA
  the paperclip pod actually runs as; there is no SA `paperclip`).
- renovate.json: group the git tag and the image pin into one PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:59:37 +00:00
atlas-botandClaude Opus 5.5 997ffbbf9b feat(network-policies): scope paperless database/monitoring ingress per workload (REN-28 PR 2b)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
default-deny-ingress in the paperless namespace admitted the whole
`database` and `monitoring` namespaces on every port, on
endpointSelector:{} — so on paperless's HTTP listener too, which trusts
X-Authentik-Username from anything that connects. Both admits are
removed and the three load-bearing flows are kept as per-workload
policies:

  pprl-pg-allow-cnpg-operator  database/cloudnative-pg -> pprl-pg   :8000
  pprl-pg-allow-prometheus     monitoring/prometheus   -> pprl-pg   :9187
  paperless-allow-blackbox     monitoring/blackbox     -> paperless :8000

No ICMP rule on them (REN-144 option (a)); the baseline ICMP and the
host/remote-node L3 admit are untouched.

Also, per plan rev 8 §5 and REN-148:
- CNPNegativeControlReachable, a second group in
  prometheusrule-blackbox.yaml, scoped to job=blackbox-negative-control.
- Comment-only corrections of every citation of the removed
  #database-admit / #monitoring-admit anchors (paperless
  network-policy, probes-blackbox, gatus header, probes-ren28-negative),
  plus the IPv4-fallback and scrapeTimeout notes on the
  negative-control Probe (PR 2a review findings 1 and 2).

gatus ConfigMap data is byte-identical (sha256 unchanged); every changed
line in that file is a comment above `data:`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:25:41 +00:00
argus-bot (Paperclip service account) ea51731207 Merge pull request 'feat(monitoring): canary the CephPoolLostRedundancy join (REN-93)' (#989) from atlas/ren-93-promql-gate into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 19:09:35 +00:00
atlas-botandClaude Opus 5.5 b03be79635 feat(monitoring): canary the CephPoolLostRedundancy join (REN-93)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
CephPoolLostRedundancy (critical) joins three metrics on `pool_id` and filters
on ceph_pool_metadata{type="replicated"}. If an upgrade or relabel renames the
label, the value, or the join key, the rule returns nothing and goes dark
silently. CephPoolLostRedundancyBlind is the same join with the thresholds
dropped, wrapped in absent(): warning, for: 1h to ride out mgr failovers.

Its six scenarios are a promtool suite at monitoring-rules/tests/ceph_test.yaml,
with five negative controls in mutations.txt, run by the existing `promtool`
step (#1026). The CI half of the original #989 is dropped: #1026 covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:04:36 +00:00
argus-bot (Paperclip service account) 9e20e31a28 Merge pull request 'monitoring-rules: critical alerts for HA, recorder DB, Mosquitto, Zigbee2MQTT (REN-245)' (#1041) from atlas/ren-245-ha-critical-alerts into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 16:57:59 +00:00
atlas-botandClaude Opus 5.5 b5e67a93bf monitoring-rules: critical alerts for HA, recorder DB, Mosquitto, Zigbee2MQTT (REN-245)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
New PrometheusRule home-assistant with four `for: 5m`, severity critical
alerts. Before this the only signal for any of them was the cluster-wide
KubePodNotReady (15m, warning).

- HomeAssistantDown: HA scrape target down or absent.
- HomeAssistantRecorderDBUnhealthy: ha-pg has no primary with Postgres up,
  or fewer than spec.instances (2) instances with Postgres up.
- MosquittoDown: no Ready broker pod (bootstrap Job pods excluded).
- Zigbee2MQTTDownOrCoordinatorOffline: pod not Ready, or HA's
  bridge_connection_state sensor reads 0.

Each alert keeps one label set across its failure modes, so one outage
is one alert with one `for` timer. Unit suite and five negative controls
added; scripts/ci/promtool-rules.sh passes locally with promtool 3.15.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:49:38 +00:00
argus-bot (Paperclip service account) 76e05c0f26 Merge pull request 'docs: HA change window, HA-impact review, HA image rollback runbook (REN-243)' (#1040) from atlas/ren-243-ha-window into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 16:42:58 +00:00
svc-atlasandClaude Opus 5.5 8ef039f8a6 docs: HA change window, HA-impact review, HA image rollback runbook (REN-243)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner decision on REN-240 (protection rule 5):
- README.md: disruptive work only 02:00-05:00 Europe/Berlin; PRs touching
  kubernetes/apps/home-assistant/ or cluster-wide disruptive changes need
  an HA-impact review (Hestia; owner until she exists).
- kubernetes/RUNBOOK-home-assistant-image-rollback.md: pin the previous
  HA tag in one commit (revert bump + drop the imagepolicy marker, so
  image automation does not re-bump it).

Docs only. No manifest changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:41:07 +00:00
k.renner bd03b61eb4 Merge pull request 'feat(openbao): secret-exists-check policy for Argus and Atlas' (#1039) from feat/openbao-secret-exists-check into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#1039
2026-09-27 16:37:25 +00:00
argus-bot (Paperclip service account) 7841177fc6 Merge pull request 'feat(renovate): weekly Renovate runner; enable on homelab (REN-216)' (#1035) from atlas/ren-216-renovate into master 2026-09-27 16:36:56 +00:00
claude-botandClaude Opus 5.5 9abe2ac820 feat(openbao): secret-exists-check policy for Argus and Atlas
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Argus and Atlas need to verify that a cluster secret exists (for example
before reviewing or merging a PR that consumes it through an
ExternalSecret) without being able to read its value. The policy grants
read/list on homelab/metadata/* only: paths, versions and timestamps, no
data, no writes. Whole tree, per owner decision.

Identity is a userpass user (house precedent: claude-bot) whose password
is one Paperclip agent secret bound to both agents. Applied once by hand
with an admin token, like the other policies in this directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:36:42 +02:00
argus-bot (Paperclip service account) 89af8c355c Merge pull request 'fix(ical-proxy): roll out main-73b5d97 (WriteTimeout above fetch timeout)' (#1038) from atlas/ren-239-ical-proxy-main-73b5d97 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 16:18:16 +00:00
atlas-botandClaude Opus 5.5 1e680eda4f fix(ical-proxy): roll out main-73b5d97 (WriteTimeout above fetch timeout)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Bump the pinned ical-proxy image from main-4783abe to main-73b5d97.
The only change is konairius/ical-proxy #9 (REN-232): the server
WriteTimeout goes from 10s to 40s (30s upstream fetch timeout + 10s
headroom), so a slow-but-legitimate upstream is no longer cut off
before /proxy can write its response.

Refs: REN-239, REN-232

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:14:06 +00:00
argus-bot (Paperclip service account) 34b59175e2 Merge pull request 'feat(ical-proxy): roll out main-4783abe (SSRF guard + /proxy concurrency cap)' (#1037) from atlas/ren-238-ical-proxy-main-4783abe into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 16:12:14 +00:00
atlas-botandClaude Opus 5.5 782ce2617e feat(ical-proxy): roll out main-4783abe (SSRF guard + /proxy concurrency cap)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Bump the pinned ical-proxy image from main-db90a59 to main-4783abe.
The new build adds two hardening changes from konairius/ical-proxy:

- #6: refuse upstreams and redirect hops that resolve to private,
  loopback, link-local or CGNAT addresses. Public redirects are still
  followed (up to 5).
- #8 (REN-231): cap concurrent /proxy upstream fetches at 20; requests
  over the cap get 429 + Retry-After instead of piling up goroutines.

Refs: REN-238, REN-231

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:10:44 +00:00
argus-bot (Paperclip service account) 7154a8dd3f Merge pull request 'fix(ical-proxy): pull the image from the Gitea registry (main-db90a59)' (#1036) from atlas/ren-201-ical-proxy-gitea-image into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 15:20:54 +00:00
atlas-botandClaude Opus 5.5 d6681702a9 fix(ical-proxy): pull the image from the Gitea registry (main-db90a59)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ical-proxy moved from GitHub to Gitea and its image is now built by
Woodpecker and pushed to gitea.renner.uno/konairius/ical-proxy. The
ghcr.io image gets no new builds.

main-db90a59 has the same application code and Dockerfile as the
running main-99a912b; the commits in between touch only CI and docs.
The package pulls anonymously, so no imagePullSecret is added.

Revert: git revert this commit; Flux rolls the pods back to
ghcr.io/konairius/ical-proxy:main-99a912b.

Refs: REN-201

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:18:15 +00:00
atlas-botandClaude Opus 5.5 7be8ebab8d fix(renovate): also skip python-matter-server, the 33rd Flux-automated image (REN-216)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review of #1035: image-automation/ has 33 ImageRepositories, the
disable list had 32. Without this, Renovate and Flux would both bump
ghcr.io/matter-js/python-matter-server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:50:53 +00:00
security-bot 3e71c32799 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.4
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-27 10:40:26 +00:00
security-bot 79188fbb59 chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:45f3e7fee1cb1a045854bce42ab189163d1dd573ba3b1b2a4c7469714ec29621
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-27 10:25:54 +00:00
argus-bot (Paperclip service account) 8d5fc041bf Merge pull request 'ha-pg: dedicated walStorage 21Gi + primaryUpdateMethod switchover (REN-176)' (#1033)
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed and approved at b9a85474c0 (review #1117). Title corrected at merge: ships 21Gi, not 18Gi.
2026-09-27 09:00:57 +00:00
atlas-botandClaude Opus 5.5 d6fb88a464 feat(renovate): weekly Renovate runner for git.renner.uno; enable on homelab (REN-216)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
- apps/renovate: CronJob (daily 04:00, routine PRs Monday only via the
  shared preset's schedule), runner config, renovate-bot token ExternalSecret.
- clusters/homelab/renovate.yaml: dedicated Flux Kustomization with the
  ExternalSecret health check from uptime-monitor.yaml.
- infrastructure: `renovate` namespace, its CiliumNetworkPolicy (DNS,
  Traefik, world:443; no ingress, no API server), and the namespace in the
  openbao ClusterSecretStore allowlist.
- renovate.json: extends local>k.renner/renovate-config. Managers flux,
  kubernetes, woodpecker, dockerfile. Skips the images Flux image automation
  already bumps, and Flux's own gotk-components. Digest pins and
  Cilium/Rook-Ceph minors get their own PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:10:49 +00:00
argus-bot (Paperclip service account) b58dea90f5 Merge pull request 'fix(monitoring): one WAL-archive rule, >5 for 20m; drop the depth rule (REN-152)' (#1019) from atlas/ren-152-wal-backlog-retune into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-27 06:41:11 +00:00
atlas-botandClaude Opus 5.5 26e9f85afd test(monitoring): regenerate cnpg-backup suite for the one-rule shape (REN-152)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#1026 (REN-161) landed a promtool suite asserting CNPGWalArchiveBacklogSurge
and `for: 15m`, and mutations targeting `for: 8m`/`for: 15m`. This PR retires
the surge rule and moves the backlog rule to `> 5` for `20m`, so master CI
would break on merge.

- Generator: drop every Surge assertion; firing cases move to t=25m.
- New case 9: the worst night in Prometheus' 27-night retention (2026-09-11,
  peak 141) as raw 30s samples, 12m pending at a 1m evaluation step.
- mutations.txt: surge-for-2m/-5m removed; backlog-for-12m added (caught only
  by case 9, the margin control); backlog-for-30m retargeted at `for: 20m`.
- README and the woodpecker step comment updated to match.

Verified: scripts/ci/promtool-rules.sh with promtool 3.15.0 (CI's pin) —
18 manifests parsed, 2 suites green, 10 mutations caught;
scripts/ci/test-promtool-rules.sh passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:39:31 +00:00
atlas-botandClaude Opus 5 d39bcff92c fix(monitoring): one WAL-archive rule, >5 for 20m; drop the depth rule (REN-152)
Retune under Zeus's ruling on REN-152: the instrument is persistence, not
depth. This was meant to land on #1010 before it merged; #1010 merged
first, so this is the same change applied to master. Still
observability-only text — no expr semantics change, nothing restarts, no
storage and no traffic path is touched.

- CNPGWalArchiveBacklog `for`: 15m -> 20m.
- CNPGWalArchiveBacklogSurge (>64 for 8m) removed.

Why the depth rule goes. The failure that costs something is an archiver
that has stopped, and a stopped archiver sits wherever it stopped — it can
park at 8 segments and never approach 64, let alone 150. A threshold tuned
above the nightly envelope is blind to exactly the case the rule exists
for, and has to be raised again every time the recorder grows. It also had
no room left: measured across Prometheus' full 27-night retention rather
than the 7 nights it was built on, >64 for 8m fires once, on 2026-09-11.

Why 20m and not 15m. Same 27-night replay, 30s resolution, all 12 CNPG
instances. Only ha-pg-1 ever exceeds 5; every other instance peaks at 2.
Longest contiguous excursion above 5 is 13.0 minutes, on 2026-09-11 at a
peak of 141. Firing evaluations: 0 at 20m and also 0 at 15m, but 15m
leaves 2 minutes of margin where 20m leaves 7, and the nightly peak moved
116 -> 141 inside the measured month. The 5 minutes of added latency buy
nothing back: a real stall lasts hours, and at 32Gi the volume holds about
four nights of stalled WAL.

The description now says outright that the 02:14Z (04:14 local) burst is
normal and self-clearing, so a page means the backlog did not clear. The
drain-rate and headroom pointers (wal.maxParallel, spec.storage.size,
REN-135) move up from the deleted rule rather than being lost.

Verified with promtool 3.5.0 at evaluation_interval 30s against the rule
file as committed: the real 2026-09-11 series is silent; a stall at 8
segments fires; a backlog filling to 100 and not draining fires; a backlog
crossing a failover still fires on time, which is what `max by (namespace,
pod)` is for. Negative controls bracket the value — at `for: 12m` the real
burst fires, at `for: 25m` the stall cases stop firing.

Revert: `git revert` this commit. That restores exactly what is on master
today, >5 for 15m plus the Surge rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 06:36:44 +00:00
argus-bot (Paperclip service account) 2db8c217f6 Merge pull request 'feat(monitoring): alert when an ExternalSecret stops syncing (REN-103)' (#1002) from atlas/ren-103-eso-alert into master 2026-09-27 06:34:41 +00:00
argus-bot (Paperclip service account) 2d114a2dc6 Merge pull request 'monitoring: pin kube-prometheus-stack chart to 91.7.0 (REN-198)' (#1034) from atlas/ren-198-pin-kps-chart into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 20:35:45 +00:00
atlas-botandClaude Opus 5.5 36fba02a55 monitoring: pin kube-prometheus-stack chart to 91.7.0
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The version range >=72.0.0 let Flux upgrade the chart unattended
(91.5.2 -> 91.7.0 at 19:43 UTC on 2026-09-26, no git commit). That
restart moved Prometheus onto k8s-1 while its Ceph RBD mounts were
broken, causing a ~48 min alerting outage (REN-198).

Pinned to the version already deployed, so reconcile is a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:34:08 +00:00
atlas-botandClaude Opus 5.5 3c318c0584 test(monitoring): move the ESO suite under tests/ so REN-161's CI runs it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
promtool-rules.sh (REN-161, merged after this branch was opened) discovers
suites only as tests/*_test.yaml. At its old path the ESO suite would have
been skipped without a word. Moved, rule_files pointed at ../ for the
stripped mirror, the "inert until REN-96" header dropped, and five negative
controls added to mutations.txt: for 5m, sum-not-max, by-namespace,
no == 1, any condition. All five are caught.

promtool 3.15.0 (the CI pin): 18 manifests parsed, 2 suites green,
11 mutations caught. Rule file unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:17:06 +00:00
atlas-botandPaperclip 8ac19539a7 docs(monitoring): record the 8h flap baseline the 15m window was held for
Comment-only. The open question on this rule was whether the Ready condition
gauge flaps in ordinary operation, which could not be answered until #994 had
been collecting for a while. It has now run 464 consecutive scrapes since
00:18Z: all 52 ExternalSecrets reconciled (959 syncs), the gauge never reached
1 in any scrape, the error counter did not move, and the scrape target never
went down. Recorded in the rule file with the queries that produced it.

Also drops a reference to prometheusrule-ceph-test.yaml, which is on the
unmerged REN-96 branch and not in this repo, so it read as an in-repo
precedent a reviewer could not find.

Non-comment lines are byte-identical to 92dbc0de in both files, so the
promtool 2.53.2 check-rules and test-rules runs recorded on the PR still hold.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 16:16:21 +00:00
atlas-botandClaude Opus 5 8014272d2b docs(monitoring): record the live series counts the rule was checked against
Comment-only. The counts the rule's reasoning rests on are now measured on the
running Prometheus rather than derived from the repo: 105 series = 52
ExternalSecrets x 2 Ready polarities + 1 Deleted tombstone, across 20
namespaces, and the ExternalSecretSyncFailing expression returns empty today.
The last point is the one a reviewer wants: the rule arrives silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 16:16:21 +00:00
atlas-bot d6c0742783 docs(monitoring): show the ESO alert group actually re-notifies, and is not inhibited
Comment-only, again: `diff` of all non-comment lines against bbaf0c6a is empty.

Answers the grouping objection raised in review. Aggregating by
`exported_namespace` leaves these alerts without a `namespace` label, so they
share one Alertmanager group — but `group_interval: 5m` on the root route
notifies a group whose alert set has CHANGED, which is not `repeat_interval:
12h` re-sending an unchanged one. A second failing ExternalSecret therefore
mails within 5m rather than being swallowed. Records the receiver this rule
routes to and why it misses all four child routes.

Also records that the three inhibit rules cannot suppress it: all key on
`equal: ['namespace', 'alertname']` and nothing shares this alertname.
OpenBaoQuorumLost in particular does not inhibit it, and being critical at 2m
arrives first anyway.

Refs REN-103
2026-09-26 16:16:21 +00:00
atlas-bot 0f1b50ece8 docs(monitoring): record why the gauge beats the counter, with the live numbers
Comment-only. No rule, expression, `for:` window, label or annotation changes —
`diff` of all non-comment lines against the previous revision is empty, so
promtool's SUCCESS and the committed unit test still hold as run.

Argus's post-merge measurement on #994 raised the ESO sync-error counter as the
better primary signal, citing the 2026-09-20 `ai/wiki-embed-basic-auth`
incident as a case a Ready gauge would miss. Reading that incident back, it
argues the other way: the key needed a manual force-sync, so it never
self-healed and Ready=False was continuous for hours, which `for: 15m` catches
with a wide margin. It was a stuck condition, not a flap.

The measured counter data points the same way: `externalsecret_sync_calls_error`
has one series cluster-wide, `ai/wiki-anthropic` at 8 errors over 47 calls, on
an ExternalSecret that is Ready=True throughout — 8 counter events, zero sync
failures.

Also states plainly what `for: 15m` does not cover: a sync that fails and
recovers on its own inside the window, which is a deliberate choice because the
Secret ends up fresh and there is nothing for a human to do.

Refs REN-103
2026-09-26 16:16:21 +00:00
atlas-botandClaude Opus 5 33a8480f53 feat(monitoring): alert when an ExternalSecret stops syncing (REN-103)
Merge B of REN-103. #994 made the series exist; this is the rule built on
them. Split on purpose — the `for:` window had to be chosen against real
data, and Prometheus held 0 series for this metric until #994 landed.

Two corrections to what REN-100 specified, both found only once there was
data or source to read:

  * `exported_namespace`, not `namespace`. The metric carries its own
    `namespace` label and the PodMonitor attaches a target label of the same
    name, so Prometheus renames the metric's to `exported_namespace` and every
    live series reads `namespace="external-secrets"`. REN-100's expression
    still fires, and reports every failing ExternalSecret in the cluster as
    living in `external-secrets`. Found by Argus against the live data.

  * `max`, not `sum`. Both give 1 today on a single controller replica, but a
    Deployment rollout is briefly scraped as two pods, where `sum(...) == 1`
    evaluates to 2 and silently stops matching.

The condition gauge is the primary signal and `externalsecret_sync_calls_error`
is deliberately unused, which reverses the guess recorded on #994. The counter
increments on strictly more paths than the condition does — a failed Get of the
ExternalSecret, a failed Get or label Patch of the target Secret, an
unresolvable SecretStore — none of which call markAsFailed(). It is the noisier
signal, not the quieter one. The gauge does not flap: ESO sets it to 1 only in
markAsFailed() and clears it on the next successful reconcile, writing both
polarities every pass, so there is no unset state in between.

`for: 15m`, because a failed sync degrades freshness and not availability —
consumers keep the last Secret ESO wrote. Long enough to ride out a rolling
restart of the 3-replica OpenBao StatefulSet, and the real outage has its own
critical alert at 2m, so nothing is hidden by waiting.

Also adds a dead-man's-switch on `absent(externalsecret_status_condition)`.
Deleting the PodMonitor or reverting the `monitoring` ingress grant would
otherwise disarm the alert silently, which is the failure mode REN-103 exists
to end; TargetDown covers a target that is failing, not one that is absent.

Verified with promtool 2.53.2: `check rules` passes, and the committed
`-test.yaml` asserts the 15m window, the rendered namespace in the annotation,
the two-pod rollout case, that the `Deleted` tombstone never alerts, and that
the dead-man's-switch fires when the series vanish. Not yet evaluated against
the live Prometheus — this runner has no cluster access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 16:16:21 +00:00
atlas-botandClaude Opus 5.5 b9a85474c0 ha-pg: walStorage 21Gi so stall survival matches today; note WAL left PGDATA (REN-176)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review #1114: 18Gi gave ~3.5 days of archiver outage, below the ~4.0 days
the shared 32Gi gives today. 21Gi gives ~4.1. The storage comment now says
WAL headroom moved to walStorage and 32Gi stays because PVCs cannot shrink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:09:54 +00:00
argus-bot (Paperclip service account) 3d6169ec05 Merge pull request 'feat(monitoring): warn before Ceph nearfull — per-OSD and replicapool headroom (REN-163)' (#1025) from atlas/ren-163-ceph-nearfull-headroom into master
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 15:09:31 +00:00
atlas-botandClaude Opus 5.5 68527e8244 ha-pg: add dedicated walStorage 18Gi, switch primary on update (REN-176)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Owner-approved option B on REN-176. Adds a separate ceph-block WAL volume
per instance and sets primaryUpdateMethod: switchover so the pod roll this
triggers promotes the rolled replica instead of restarting the primary in
place.

Permanent: the CNPG 1.30.1 webhook rejects removing walStorage, so a git
revert of this commit fails admission. Merge only in an owner-named window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:06:01 +00:00
argus-bot (Paperclip service account) 0e2dea3ee2 Merge pull request 'fix(velero): read-only snapshot backup PVC for ceph-filesystem-bulk (REN-177)' (#1030) from atlas/ren-177-velero-cephfs-readonly-backuppvc into master 2026-09-26 15:01:11 +00:00
argus-bot (Paperclip service account) 7acdb36d52 Merge pull request 'uptime-monitor: attach owner's alert contacts to the pilot monitors (REN-7)' (#1031) from atlas/ren-7-alert-contacts into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 11:47:00 +00:00
atlas-botandClaude Opus 5.5 56e9814437 uptime-monitor: attach owner's alert contacts to the pilot monitors (REN-7)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both pilot EndpointMonitors were created with an empty alert_contacts
list, so they notified nobody. Set uptimeRobotConfig.alertContacts to the
two contacts every hand-made monitor in the account already uses
(7880965 e-mail, 8026823 type 13, threshold 0, recurrence 0), in the
order the API returns them so IMC's string comparison stays stable.

Per the owner's ruling on REN-138 no test alert is fired; delivery is
owner-verified. Revert this commit to return to empty contacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:45:53 +00:00
argus-bot (Paperclip service account) ff085a0586 Merge pull request 'ci: cite YAML by anchor instead of line number, and guard it (REN-183)' (#1027) from atlas/ren-183-yaml-anchors into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 10:38:26 +00:00
atlas-botandClaude Opus 5.5 25acbd4f1e fix(velero): read-only snapshot backup PVC for ceph-filesystem-bulk
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
backupPVC in node-agent-config is keyed by source storage class.
ceph-filesystem-bulk had no entry, so its data-mover took the full
CephFS subvolume clone path and sat Pending on "unbound immediate
PersistentVolumeClaims" (4 min audiobookshelf, 22 min immich on
2026-09-26, paging KubePodNotReady). Growth with data would hit the
data-mover prepare timeout and fail the backup.

- node-agent-config: ceph-filesystem-bulk -> readOnly: true with
  storageClass ceph-filesystem-bulk-snapshot-readonly
- new StorageClass ceph-filesystem-bulk-snapshot-readonly:
  backingSnapshot "true" (shallow RO volume, no clone), reclaimPolicy
  Delete
- nodeAgent.podLabels revision label so node-agent restarts and loads
  the config (node-agent only, not the server)

Refs REN-177.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:35:39 +00:00
security-bot 5bdb29e1e0 chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:1da39019a9b48c2b9318a5f037978765d226c8ef6838545fa691f3f8200bccac
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-26 10:25:33 +00:00
k.renner 783a680184 Merge pull request 'feat(paperclip): LAN-only API door for external agents' (#1029) from feat/paperclip-api-door into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1029
2026-09-26 09:24:20 +00:00
claude-botandClaude Opus 5.5 053e0f1a9b feat(paperclip): LAN-only API door for external agents
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Agents outside the pod (Claude Code on the workstation, via
`paperclipai agent local-cli` / agent API keys) only make outbound REST
calls to /api/* with Authorization: Bearer. The Authentik gate on
paperclip.renner.uno rejects those (intercept_header_auth), so they get a
separate machine door, following the wiki-index pattern:

- paperclip-api.renner.uno (no dns.renner.uno/public label: LAN-only)
  routes /api to the paperclip Service, bypassing the gate. Paperclip's
  bearer agent/board keys are the authentication.
- /api/auth on that host is refused (Traefik ipAllowList matching no
  address -> 403), so Better Auth password sign-in/sign-up stays behind
  the gate; upstream serves all password login under /api/auth.
- The host is added to PAPERCLIP_ALLOWED_HOSTNAMES (private-hostname
  guard would 403 it) and the CNP admits Traefik on 3100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:21:48 +02:00
atlas-botandClaude Opus 5.5 21a7583523 ci: cite YAML by anchor instead of line number, and guard it (REN-183)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comments that pointed at other YAML as `file.yaml:N` went stale on any
edit above the cited line; three drift fixes had been merged by hand
(REN-181). The cited block now carries `# anchor: <name>` and the
comment cites `path.yaml#<name>`.

- Sweep: all 32 line-number citations in YAML comments converted.
  Several were already stale at head and now point at the right block
  (see PR). Vendored files (Flux gotk-components, IMC CRD) get no
  anchor; those two citations name the object/schema path instead.
- Guard: scripts/ci/yaml-citations.sh in ci-manifests-test.yml; fails
  on a line-number citation or a path#name whose file/anchor is gone.
  Self-test in ci-scripts-test.yml.
- CONTRIBUTING.md: the convention in three lines.

Comment-only under kubernetes/: every file parses to identical objects
before and after, so Flux applies nothing and no ConfigMap data changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:44:54 +00:00
argus-bot (Paperclip service account) 2608c264d3 Merge pull request 'ci(monitoring-rules): machine-check PromQL with promtool check rules + test rules (REN-161)' (#1026) from atlas/ren-161-promtool-ci into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 08:32:25 +00:00
argus-bot (Paperclip service account) bc1c69d356 Merge pull request 'Correct the "all six controllers" claim on gotk_reconcile_duration_seconds (comment-only)' (#1023) from atlas/ren-127-podmonitor-comment into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 08:26:04 +00:00
9a3c6ef9af ci(monitoring-rules): machine-check PromQL with promtool (REN-161)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing in this repo checked an alert expression. ci-manifests-test.yml proved
every PrometheusRule assembled into valid YAML and stopped there — no check that
an expression parses, that a label join matches anything, or that a `for` window
behaves the way its comment claims.

On REN-129 the CNPG surge rule was written `for: 2m` and reviewed at that value.
Against Iris's seven measured nights it fires on six of them: two owner emails a
night, 04:16 and 04:22 local, for a WAL backlog that clears itself in under ten
minutes. The arithmetic that caught it was hand-written from scratch twice, in
two runs, and thrown away both times because there was nowhere in the repo to
commit it. It is now a negative control.

Adds:

* scripts/ci/promtool-rules.sh — discovers every PrometheusRule (17 today),
  strips `spec:` into a temporary mirrored tree, runs `promtool check rules` on
  each, then `promtool test rules` over the committed suites. Discovery floors
  on both, as in the sibling steps.
* a `promtool` step in ci-manifests-test.yml, `depends_on: []` like the other
  four, on prom/prometheus:v3.15.0. The 3.x pin is load-bearing: the cluster
  runs kube-prometheus-stack >= 72.0.0, and alert `for` evaluation is
  version-sensitive, so a 2.x promtool would check a different evaluator than
  production. The script asserts the major version it is handed.
* kubernetes/apps/monitoring-rules/tests/ — the REN-129 suite (14 cases over
  the seven measured nights, an adversarial envelope built from the extremes of
  the measured fill/drain ranges, two stall shapes, a threshold boundary, a
  failover, a multi-instance case and the epoch-zero guard), its generator, and
  six negative controls in mutations.txt.
* a mutation phase. Each control breaks one value the rules depend on and the
  suites must fail; a control whose sed matches nothing is a failure, not a
  skip.
* scripts/ci/test-promtool-rules.sh, run from ci-scripts-test.yml — 17 cases
  asserting the script refuses to report success when it has checked nothing.

The mutation phase is not belt-and-braces, it is the point. Two failure modes
bit while writing this and neither changed promtool's exit code: a `rule_files`
entry that did not resolve, where promtool WARNs, loads zero rules, and passes
every `exp_alerts: []` assertion against an empty rule set; and traces generated
against the wrong clock window, where all seven nights were flat zero. Both were
green. A suite that passes proves the rules match the assertions, not that the
assertions reach the rules.

Production: nothing applied to the cluster. No manifest changed, so the
monitoring-rules kustomization builds identically — its `resources:` list is
explicit, and the new tests/ directory is not in it and has no kustomization.yaml
of its own, so Flux does not see it. Worst case is a red step on PRs touching
kubernetes/**; enable_status_check is false on master, so it cannot block a merge
on its own.

Verified with real promtool 3.15.0 against the real tree: 17 manifests parsed
(56 rules), the suite green, all 6 mutations caught. Run again under BusyBox
1.35.0 sh/awk/sed/find/xargs, the toolchain prom/prometheus actually ships.
Guard tests 17/17 under both bash and BusyBox sh. shellcheck 0.11.0 clean at
every severity. woodpecker-cli 3.18.0 lint clean on all 17 pipeline files.
Not run in Woodpecker itself — this PR is its first real pipeline execution.

Revert: revert this commit. It adds files and one step; nothing else reads them.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 08:25:59 +00:00
argus-bot (Paperclip service account) a003e1c126 Merge pull request 'test(uptime-monitor): restore the audiobooks EndpointMonitor, and correct the 403 reading — REN-7' (#1024) from atlas/ren-7-restore-audiobooks into master 2026-09-26 08:18:37 +00:00
argus-bot (Paperclip service account) 25b6fa573f Merge pull request 'docs(monitoring-rules): repoint stale gatus citation 176-185 -> 205-214 (REN-128)' (#1008) from atlas/ren-128-probes-blackbox-citation into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 08:14:37 +00:00
8d2323b347 feat(monitoring): warn before Ceph nearfull, per-OSD and replicapool (REN-163)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Ceph's mon_osd_nearfull_ratio is 0.85 and nothing reported the approach to
it, so the first signal would have been Ceph flagging nearfull — already the
incident. With failureDomain host, size 3 and three OSDs there is no
rebalance to hide behind: all three cross together.

Two warning rules, both observability only:

- CephOSDNearfullApproaching: fullest OSD sustained >= 75% for 30m. 0.75 is
  0.85 minus the largest measured bulk burst (+10.24 pp in 25 min, 09-26
  05:18-05:43Z), so it marks the point where one more such burst reaches
  nearfull. 30m is the shortest wait that outlives a self-reclaiming burst
  (that burst held >= 75% for 19.0 min) and short enough to fire before 0.85
  at the fastest observed sustained climb (+11.44 pp/h).

- CephReplicapoolHeadroomLow: replicapool max_avail < 160 GiB for 30m.
  160 = 89 GiB held above the 0.85 line + 38 GiB ha-pg has not yet consumed
  of its approved 32Gi + 35 GiB observed replicapool churn.

Replayed against the 14d of real series to 2026-09-26 08:00Z: both fire zero
times. Worst 30m-sustained OSD floor 71.632%; lowest 30m-sustained
replicapool ceiling 208.97 GiB. Both expressions evaluated against live
Prometheus at the 09-26 05:40Z peak do select (3 OSDs at 81.87%,
replicapool at 117.41 GiB), so the 30m `for` is what keeps that excursion
quiet, not a join that never matches.

Thresholds are unmerged pending the owner's yes on REN-163.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 08:11:45 +00:00
argus-bot (Paperclip service account) ea0abda79c Merge pull request 'fix(uptime-monitor): fail only on the ESO reasons that never retry (REN-106)' (#999) from atlas/ren-106-narrow-eso-failed into master 2026-09-26 08:10:58 +00:00
atlas-botandClaude Opus 5 8967db1001 test(uptime-monitor): restore the audiobooks EndpointMonitor, and correct the 403 reading — REN-7
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Criterion 3 passed: the removal test (#1018, merged 101c11f4) deleted monitor
804092841 and the controller logged `Monitor Removed` at 03:57:58Z. This commit
is the deliberate revert of that test, which Zeus sequenced to run only after
the cap reading below. The restored object is byte-identical at the object level
to the version that provably created 804092841.

It also corrects a wrong claim this file asserted. The header stated that a
monitor-count cap was EXCLUDED as the cause of the `www.renner.uno` 403, on the
reasoning that a cap would arrive as HTTP 200 + `stat: fail`. The removal test
refuted that:

  03:26:27-03:56:27  account 10/10, seven consecutive `Status Code: 403`
  03:57:58           804092841 deleted, account 9
  04:01:27           `Monitor Added: www.renner.uno-uptime-monitor`, id
                     804092931, account back to 10

One freed slot is the only variable. UptimeRobot returns HTTP 403 when the
account monitor limit is reached, so "account is full" is the first hypothesis
for an IMC 403, not a discarded one. Capacity is now settled on fact for
REN-158; REN-8's 18-monitor page against a cap of 10 stays blocked on it.

Expect this restored create to be refused with 403 until the tier is raised —
`www` took the freed slot. Left in git per the capacity ruling so the retry loop
creates it unaided once capacity exists. `audiobooks.renner.uno` stays covered
by its 60s Gatus check throughout, so there is no coverage gap.

Also recorded: the real delete log string is `Removing monitor … from provider`
/ `Monitor Removed`, not the `Deleting Monitor` this file predicted; and the
status page is in all-monitors mode, proven in both directions (9->10 on create,
10->9 on prune) with no API key, which settles REN-9's monitor-list mode.

Verified: js-yaml loadAll gives 2 EndpointMonitors in `uptime-monitor`; the only
functional changed line is the restored object, every other changed line is a
comment; restored spec compared equal to `f20fe8bf^`. Not applied — Flux applies
from master and I cannot merge my own work.

Revert: revert this commit. Flux prunes the object and, because its monitor does
not exist, nothing in UptimeRobot changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 08:08:29 +00:00
ee4ea92a9e Correct the "all six controllers" claim on gotk_reconcile_duration_seconds
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#992's header comment says RecordDuration "IS called, by all six controllers",
established by grepping the tagged upstream source trees. Now that the scrape is
merged the claim is measurable, and five of six emit the family: 170 series from
kustomize (33), source (43), image-reflector (68), helm (25) and image-automation
(1), and zero from notification-controller.

Five is permanent, not today-only. notification-controller's three reconcilers
are registered but never walked -- controller_runtime_reconcile_total for
controller=alert/provider/receiver reads 0 over 56 h of uptime with 2 Alerts and
1 Provider live in the apiserver -- so the family is absent from its /metrics
entirely and creating a Flux Alert or Provider does not produce the sixth series.

Comment-only. Every one of the 17 changed lines is a pure comment line and the
comment-stripped file is byte-identical to master (same sha256), so the object
Flux applies is unchanged and no pod rolls.

Refs REN-127, REN-102, REN-97

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 08:08:03 +00:00
k.renner f96f04cd9d make paperclip externaly accasable
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 07:42:38 +02:00
argus-bot (Paperclip service account) 101c11f476 Merge pull request 'test(uptime-monitor): remove the audiobooks EndpointMonitor — REN-7 criterion 3 removal test' (#1018) from atlas/ren-7-delete-test-audiobooks into master
ci/woodpecker/cron/alert-monitor Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
2026-09-26 03:56:13 +00:00
c2afbcdd4d docs(monitoring-rules): repoint stale gatus citation 176-185 -> 205-214 (REN-128)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
probes-blackbox.yaml:209 cited apps/gatus/configmap.yaml:176-185 as the
place gatus documents the paperless blind spot. That was true at cb9e73bd,
where the sentence was written. A later +29-line insert into configmap.yaml
pushed the block down; at master those lines are now Web Service 9
(music.renner.uno), which carries no prose at all.

The same 10-line Paperless blind-spot comment now runs 205-214.

Comment-only. No spec, no pod template, no new resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 03:55:53 +00:00
argus-bot (Paperclip service account) 5f8b6ca0a9 Merge pull request 'feat(monitoring): Flux per-object readiness via kube-state-metrics (REN-102)' (#1013) from atlas/ren-102-flux-ksm-crs into master
Reviewed and merged by argus-bot (REN-145). Approved at head 088db00e.

Independently verified: the eight GVKs against the single served CRD version in Flux v2.9.5; every CRD field path the config reads; object census = 149 and the reconcile intervals behind the 2h/1h thresholds; the rendered chart keeps --resources and omits --custom-resource-state-only; the whole rendered ClusterRole uses only list+watch with no wildcard; the real kube-state-metrics v2.20.0 binary accepts the rendered config while a malformed control is rejected at startup; the kustomize CI gate green on the simulated merge result, which CI itself did not test.

Own promtool fixtures: 9 alert tests plus a 13.5-day recording-rule simulation, all passing. Warm-up guard empty at 1201 samples and populated at 1297; the pruning case yields 0 under the shipped comparator against 1.1376 under the withdrawn ratio; tag counting survives a kube-state-metrics pod roll.

The missing staleness alert is an accepted, measured reduction; follow-ups tracked in REN-143.

Revert: git revert -m 1 <this merge commit>. Both Kustomizations are prune: true, so nothing is orphaned.
2026-09-26 03:54:03 +00:00
argus-bot (Paperclip service account) 15e81f8301 Merge pull request 'fix(cilium): declare the agent metrics Service so cilium_* is scraped (REN-147)' (#1017) from atlas/ren-147-cilium-agent-metrics-service into master
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).

Verified against the PR head 17c1dece:
- helmrelease.yaml diff is comments only; parsed YAML of HEAD^ and HEAD is byte-identical (1683 bytes each). No values change, so no helm upgrade and no cilium agent restart.
- ServiceMonitor selector {managed-by: homelab, name: cilium-agent} matches only the new Service; endpoint port `metrics` exists on it; targetLabels k8s-app=cilium present.
- Service selector k8s-app: cilium, headless, distinct name from the chart's cilium-agent Service - no double-scrape or ownership collision if a chart flag is flipped later.
- Revert is one step: the monitoring-rules Flux Kustomization has prune: true, so git revert removes the Service.
- ci-manifests-test and ci-scripts-test green; advisory review-bot: no blocking issues.

REN-156
2026-09-26 03:53:28 +00:00
argus-bot (Paperclip service account)andPaperclip ec7a82eca9 feat(home-assistant): grow ha-pg storage 20Gi -> 32Gi (REN-141) (#1014)
Approved by argus-bot at head c1b9b259 after re-review (head moved from dd6720f2).
Ceph capacity gate cleared at 03:49Z: replicapool max_avail 168.8 GiB usable, per-OSD 76.1% -> 78.8% worst case against nearfull 0.85, HEALTH_OK.
Four eyes: authored by atlas-bot, merged by argus-bot.
PVCs do not shrink - manifest reverts with git revert <merge-sha> -m 1, volumes do not.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 03:52:21 +00:00
argus-bot (Paperclip service account) d9e54b40e9 Merge pull request 'feat(monitoring): make a stalled WAL archiver reportable (REN-129)' (#1010) from atlas/ren-129-wal-archive-backlog-visibility into master
Approved at 7aff5250 (review 1086). CNPGWalArchiveBacklog for: 30m -> 15m; new CNPGWalArchiveBacklogSurge > 64 for 8m; both exprs wrapped in max by (namespace, pod) so a failover does not restart the for timer.

Verified with promtool 3.5.0: 12-case test rules suite over Iris's seven measured nights plus an adversarial envelope case, all passing, with five negative controls each failing. Revert tested: git revert restores the file byte-for-byte.

Closes REN-129.
2026-09-26 03:51:58 +00:00
atlas-botandClaude Opus 5 f20fe8bf61 test(uptime-monitor): remove the audiobooks EndpointMonitor — REN-7 criterion 3 removal test
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Deletes the `audiobooks.renner.uno` EndpointMonitor so that the other half of
REN-7 can be proven: an EndpointMonitor creates an UptimeRobot monitor, and
deleting it removes that monitor again. "A monitor that cannot be removed is a
leak." The create side is already shown — monitor 804092841, created
2026-09-26 03:26:27, controller log `Monitor Added`.

What to observe after this merges, keyless and from either side:

  * `https://uptime.renner.uno/api/getMonitorList/UgMXmP1iRV` goes from
    `psp.totalMonitors: 10` to `9`, and id 804092841 is absent.
  * controller log: `Deleting Monitor: audiobooks.renner.uno-uptime-monitor`.

Precondition, checked before writing this: v2.2.13 carries no finalizer, so a
delete while the controller is down is never reconciled and orphans the monitor
permanently. Pod ingressmonitorcontroller-5d577cc58-pgqwg was Ready=True with
0 restarts. Whoever merges this must re-check that the pod is Ready first.

Also records, in the file, why the second pilot object still has no monitor:
UptimeRobot answers its create with HTTP 403 and IMC retries every 300s
forever. 403 is the non-200 branch of `Add()`, so it is not the provider's
validation path — that returns 200 with `stat: fail` — which rules out the
duplicate-URL, monitor-cap and bad-parameter guesses. Tracked on REN-155; the
object is left in place as the reproducer.

Blast radius: removes one EndpointMonitor from `uptime-monitor` and one
UptimeRobot HTTP check every 300s. No Ingress, workload, ingress controller,
DNS record or RBAC rule is touched, and `www.renner.uno` is unchanged.

Revert: revert this commit. Flux re-applies the object and the controller
re-creates the monitor under the same friendly name — a new id, since the name
and not the id is what binds object to monitor. That round trip is also the
idempotent-reconcile check and is worth doing deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:50:54 +00:00
c1b9b259b6 docs(home-assistant): correct the WAL stall attribution in ha-pg sizing (REN-141)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The sizing comment blamed the 10-minute archiving stall on Velero repository
maintenance. REN-136 disproves that over seven nights:

  - sustained archiver drain is ~14 segments/min, flat whether Kopia is
    running or not, so Kopia does not slow the drain;
  - Kopia's nearest batch drifts across 02:03-02:24Z while the burst stays
    pinned to 02:13Z, and a cause that drifts 20 minutes cannot produce an
    effect fixed to the minute.

The real driver is a nightly producer burst: 56-119 segments queued at
02:13-02:15 UTC on all seven nights, against a steady-state queue of 0.
Comment now states that, and adds REN-136's stalled-night framing (6.36 GiB
free survives one stalled night, ~18.6 GiB carries about four).

Comment text only. `spec.storage.size: 32Gi` is unchanged and still the sole
semantic change on this branch — verified by parsing origin/master and HEAD
with js-yaml: with spec.storage.size masked the two parse to identical
objects.

Raised by Argus in review of #1014 as a non-blocking finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 03:47:59 +00:00
argus-bot (Paperclip service account) 02a23d1670 test(monitoring): negative-control Probe for paperless ingress scoping (REN-28 PR 2a) (#1016)
Reviewed at head 3b96b3ab, test-merged clean into master dd2256ea (tree af52adab).

Verified: one file +73/-0; 0 YAML parse failures repo-wide; jobName blackbox-negative-control unique; all four ports re-derived from the paperless Service/Cluster objects; tcp_connect module declared; paperless-ns.yaml still admits monitoring namespace-wide so the controls read 1 today.

Pages nobody: all 16 PrometheusRules enumerated, the only non-job-pinned probe_success rule (HetznerBackupEndpointDown) is instance-pinned to the Hetzner URL; the chart's up-based TargetDown cannot fire because blackbox answers 200 with probe_success 0 and the 5s module timeout stays inside the 10s scrapeTimeout; all 5 blackbox-overview.json panels are pinned to job=blackbox-http; no Grafana-managed alert references probe_success.

Monitoring-only, no traffic-path change. Revert with: git revert -m 1 <this-sha> on a branch, through a PR.

REN-28 §6.4 gate NOT yet taken - reviewer has no Prometheus credential either. PR 2b must not open until the probe_success == 1 reading exists.
2026-09-26 03:46:43 +00:00
17c1dece48 fix(cilium): declare the agent metrics Service so cilium_* is scraped (REN-147)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The cilium chart renders templates/cilium-agent/service.yaml only when
prometheus.enabled AND (prometheus.serviceMonitor.enabled OR
prometheus.metricsService). infrastructure/cilium/helmrelease.yaml sets the
first and neither of the second, so no cilium-agent Service existed. The
standalone ServiceMonitor in apps/monitoring-rules selected a Service that was
never there: scrape pool serviceMonitor/kube-system/cilium-agent/0 sat at 0
active / 46 dropped targets and cilium_* had zero series.

Consequence: CiliumBPFMapPressureHigh and CiliumBPFMapFull read
cilium_bpf_map_pressure, so both evaluated an empty vector and could never
fire — the exact failure the helmrelease comment says the metrics were turned
on to prevent. cilium_policy_verdict_total and cilium_drop_count_total were
equally absent, which is why REN-140 needed a purpose-built prober to observe
a network-policy deny.

Declares a sibling metrics Service next to the ServiceMonitor instead of
setting prometheus.metricsService in the HelmRelease. The flag is the
chart-native fix and renders exactly this one object, but it is a values
change: helm-controller would upgrade the CNI release and three-way-merge
against live objects, and the configmap.reloader annotation turns any
resulting cilium-config change into a rolling restart of every agent. A
Service applied by the apps Kustomization cannot do that.

Naming follows authentik-outpost-servicemonitors.yaml: distinct name plus
app.kubernetes.io/managed-by: homelab, with the ServiceMonitor selector pinned
to it, so turning either chart flag on later neither collides on ownership nor
double-scrapes the same endpoints.

The helmrelease.yaml change is comment only — verified by rendering the chart
against the values block before and after: byte-identical, so Flux performs no
helm upgrade.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:44:02 +00:00
argus-bot (Paperclip service account) dd2256ea05 test(monitoring): temporary negative-test probes for REN-28 §7 (REN-140) (#1015)
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed and merged by argus-bot (REN-149). Author atlas-bot; four-eyes split intact.

Verified live: no alerting rule can match job="blackbox-ren28-negative" (24 probe_/up/scrape_ rules enumerated; tcp_connect module timeout 5s < scrapeTimeout 10s keeps up==1); pod IPs 10.244.4.166/10.244.2.220 current; probeSelector strict so the release label is load-bearing and is set; monitoring has no egress enforcement.

Temporary. Delete probes-ren28-negative.yaml and its kustomization line once the REN-28 "after" reading is recorded.

Revert: git revert -m 1 <this sha>
2026-09-26 03:43:58 +00:00
argus-bot (Paperclip service account) d3f20d6d16 Merge pull request 'feat(netpol): scope the navidrome monitoring ingress to blackbox-exporter (REN-28)' (#1012) from atlas/ren-28-navidrome-monitoring-scope into master
ci/woodpecker/cron/issue-fix Pipeline was successful
REN-28 item 3a. Narrows the navidrome baseline CNP's whole-monitoring-namespace, every-port ingress admit to the blackbox-exporter pod on :4533 and :8686, and repoints two comments elsewhere in the tree that cited the replaced rule.

Reviewed by argus-bot at head 1c1a637c in a private detached worktree. ci-manifests-test and ci-scripts-test green at that head. Blocking review 1065 (a false ICMP justification) resolved by deletion in 1c1a637c.

Revert: git revert -m 1 <this sha>, through a PR.
2026-09-26 03:40:22 +00:00
7aff525001 feat(monitoring): make a stalled WAL archiver reportable (REN-129)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ha-pg-1 queues WAL for archiving every night: flat 0 until 02:12Z, peak
02:14-02:15Z, drained by 02:22Z, peaks of 56-119 segments on the seven
nights measured 2026-09-19 to 2026-09-26. Point-in-time recovery for
home-assistant is degraded for the ~10 minutes each burst lasts. Nothing
fires, because CNPGWalArchiveBacklog's `for` was 30m.

The cause is a write burst, not IO contention. Drain was a flat 13.0-14.9
segments/min on all seven nights, including the night whose Kopia
repository-maintenance batch started only after the burst had drained;
that batch drifts across 02:03-02:24Z while the peak stays pinned to
02:15Z. Fill is what varies, 18.7-39.7 segments/min. 02:12Z is 04:12
local, when Home Assistant's recorder purge runs. An earlier revision of
this commit named Velero/Kopia as the cause; the 7-night data retracts
that, and the annotations no longer send anyone to the velero namespace.

Three changes, all alert-rule text. Nothing restarts, no storage and no
traffic path is touched.

- CNPGWalArchiveBacklog `for`: 30m -> 15m. This does not fire on the
  nightly burst at either value, and should not: every burst in the week
  drained itself, and the longest contiguous excursion above 5 was 10
  minutes. 15m is for the archiver that has actually stopped, which is
  the failure that fills the PVC and stops Postgres.

- New CNPGWalArchiveBacklogSurge, >64 segments (1 GiB) for 8m, warning.
  Covers the shape the 15m rule cannot see: a working archiver outrun by
  production. 8m because the measured envelope spends at most 5
  consecutive minutes above 64 on any night, so 8m clears the worst
  observed night by 3 minutes while still catching 1 GiB that is not
  draining -- from 119, a healthy archiver is back under 64 inside 5
  minutes.

- Both exprs wrapped in `max by (namespace, pod)`. `role` flips on
  failover, a changed label is a new series, and a new series restarts
  the `for` timer. On the raw series a backlog that persists through a
  failover at minute 11 is silent at 16m and 20m and only pages by 25m.

Verified with promtool 3.6.0 against the measured traces, not applied to
the cluster: `check rules` passes, and unit tests cover the ordinary
night, the worst night, the quiet night, a stalled archiver, and a role
flip mid-backlog. Negative controls confirm the tests have teeth -- at
`for: 2m` the surge rule fires at 02:17Z with $value 99 on the ordinary
night, and without `max by` the role-flip case fails.

Revert: `git revert <this commit>` restores both rules exactly. No state
outside Prometheus's rule evaluation is affected either way.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:37:15 +00:00
3b96b3ab1c feat(monitoring): negative-control Probe for paperless ingress scoping (REN-28 PR 2a)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adds one Probe, `blackbox-negative-control-paperless`, with its own jobName
`blackbox-negative-control` and four tcp_connect targets in the `paperless`
namespace. The targets exist to be REFUSED: each must read probe_success == 1
at this commit and 0 once REN-28 PR 2b narrows the whole-namespace `monitoring`
admit in paperless-ns.yaml. That flip is the negative test, and it then stays
on as a standing regression alarm.

This is monitoring-only. No traffic-path change, no restart, no new grant, no
policy edit. blackbox-exporter already runs in `monitoring`, whose baseline CNP
is ingress-only, and default-deny.yaml is scoped `namespace: apps`, so the
paperless ingress policy is the only variable that can move these controls.

Deliberately silent: both blackbox alerts are scoped job="blackbox-http", so a
distinct jobName is outside them and four permanently-failing targets page
nobody after 2b. The inverted alert CNPNegativeControlReachable ships in PR 2b,
not here — between 2a and 2b these controls are supposed to read 1.

Appended at end of file so existing line-number citations (including
paperless-ns.yaml's reference to probes-blackbox.yaml:250) do not shift.

Refs REN-28 §5.6, §6.4. Revert: drop this one Probe document.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:34:55 +00:00
atlas-botandPaperclip 1c1a637c66 fix(netpol): drop the entity-scoped ICMP allow from navidrome (REN-144)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The `fromEntities: cluster` + `icmps` rule added earlier in this branch was
justified by a false premise. Its comment claimed the new `toPorts` was "the
first layer 4 ingress rule in this namespace" — at f177685d the ingress already
ended in an `icmps:` rule, which is itself a layer 4 rule. So the allow was
neither required by this change nor new with it.

It also did not match its own header ("ICMP from the host and the other nodes"
vs `fromEntities: cluster`), and for pods it was redundant: IPv4 type 3 and
IPv6 types 1 and 2 are already in the namespace-wide ICMP rule below it.

ha-pg-allow-cnpg-operator does not transfer as precedent: it is pod-scoped and
carries no `host`/`remote-node` allow, so `cluster` is the only thing admitting
the host there. Four peer baselines with exactly this shape — monitoring,
home-assistant, ai, external-secrets — carry no entity-scoped ICMP allow, and
monitoring is where blackbox-exporter and Prometheus themselves run.

The branch is now exactly the approved REN-28 §4.1 scope: the monitoring
namespace admit is narrowed to the blackbox-exporter pod on 4533/8686, and
nothing else in navidrome-ns.yaml changes.

Revert: `git revert` this commit restores the allow; reverting the whole PR
restores the namespace-wide monitoring admit.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 03:33:25 +00:00
6ee36d9f61 test(monitoring): temporary negative-test probes for REN-28 §7 (REN-140)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Two of the four REN-28 §7 negative tests need a prober inside `monitoring`
that is not the workload under test. `pods/exec` in `monitoring` was declined
as a wider grant than the change it verifies, so this uses the prober the
cluster already runs: blackbox-exporter, driven by a Probe object, read back
through the existing read-only REN-36 Prometheus grant.

Adds one Probe, jobName `blackbox-ren28-negative`, module tcp_connect, four
targets that are reachable from `monitoring` TODAY and that the REN-28
paperless PR must close:

  10.244.4.166:9187 / 10.244.2.220:9187   pprl-pg metrics port  -> §7 test 4
  pprl-pg-r.paperless.svc:5432            same, stable name
  gotenberg.paperless.svc:3000            another paperless-ns workload -> §7 test 2

This is the "reachable before" half. It can only be taken while the current
policy is still in force, which is why it goes in ahead of the paperless PR.

No alert can match it: every rule in prometheusrule-blackbox.yaml is scoped to
job="blackbox-http", and TargetDown cannot fire because a Probe's scrape target
is blackbox-exporter itself, which answers 200 with probe_success 0 on a failed
probe.

No workload is touched, no restart, no policy change, no secret. Revert is
deleting the file and its kustomization line.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:33:08 +00:00
dd6720f28d feat(home-assistant): grow ha-pg storage 20Gi -> 32Gi (REN-141)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
One semantic line: spec.storage.size 20Gi -> 32Gi. The sizing comment above it
is rewritten in the same commit because it justified 20Gi on table growth
("leaves room for autovacuum bloat and index churn"), which is no longer the
reason for the number.

The reason is WAL headroom during an archiving stall. 20Gi left 6.63 GiB free;
when WAL archiving stalled for 10 minutes under Velero repository maintenance
(REN-129) the un-archived backlog hit 1.84 GiB, 28% of that free space. Postgres
cannot recycle a WAL segment until the archiver ships it, so a longer stall
walks the volume toward full and a full PGDATA stops the recorder. 32Gi takes
free space to ~18.6 GiB and the same backlog to 10% of headroom.

Owner approved option A on REN-135 (apply now, do not gate on the capacity
read). Options B (archiver maxParallel test) and C (throttle Velero) were
declined, so ha-pg-objectstore.yaml and Velero are untouched.

Verified by parsing the file before and after: with spec.storage.size masked,
the two documents are byte-identical as parsed objects. instances: 2,
storageClass: ceph-block, all 8 postgresql.parameters and resources are
unchanged.

NOT verified here: `ceph df` for the replicapool pool. This runtime has no
kubectl and no Ceph read path — see the PR body.

Revert: `git revert <merge-sha> -m 1` restores the manifest, but the PVC
expansion itself is NOT reversible; PVCs do not shrink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 03:30:40 +00:00
atlas-botandClaude Opus 5 088db00e62 feat(monitoring): Flux per-object readiness via kube-state-metrics (REN-102)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Per-object Flux readiness cannot come from the controllers: gotk_reconcile_
condition and gotk_suspend_status are declared by fluxcd/pkg/runtime and
populated by nothing at the pinned tags, so the REN-97 scrape yields 2,273
gotk_* series and none of them are about readiness. This turns on
kube-state-metrics custom-resource-state over eight Flux kinds instead, and
builds the standing not-Ready alerts on it.

The not-Ready half is complete. The Ready-but-frozen ImagePolicy half ships
as recording rules only: no threshold is derivable from the history that
exists today, and the measurements are in the rule file rather than left
implicit.

- kube-state-metrics customResourceState + rbac.extraRules (list, watch only)
- prometheusrule-flux.yaml: FluxImageAutomationNotReady (2h),
  FluxResourceNotReady (1h), FluxImagePolicyMetricsMissing (absent, 30m),
  and four recording rules for the staleness signal
- image-automation README: the "What watches this" section said both cases
  were uncovered

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:27:41 +00:00
argus-bot (Paperclip service account) 560b3588ea Merge pull request 'feat(uptime-monitor): pilot EndpointMonitors for www and audiobooks (REN-7)' (#1011) from atlas/ren-7-pilot-endpointmonitors into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed and merged by argus-bot (four-eyes: authored by atlas-bot).

Review 1063 on REN-137. Claims 1-3 re-derived from IMC v2.2.13 source and the
vendored CRD; host choice checked against the live status-page feed (9 baseline
monitors, neither target present). Revert: revert this merge commit; prune=true
plus enableMonitorDeletion=true returns the account to the 9-monitor baseline.

Recorded finding (non-blocking): UpTimeMonitorService.Add() returns nothing and
logs every failure, so a rejected newMonitor call is invisible in Kubernetes and
in Flux. A negative status-page result must not be read as "explicit-list mode"
for REN-9 without a controller-log read.
2026-09-26 03:25:15 +00:00
argus-bot (Paperclip service account) f04d37aba4 Merge pull request 'feat(paperclip): bind the built-in view ClusterRole to the paperclip SA (REN-111)' (#1003) from atlas/ren-111-cluster-view-binding into master
Implements the owner's decision on REN-52 (2026-09-25, option B), tracked by REN-111: one ClusterRoleBinding, built-in `view` ClusterRole, subject the paperclip/paperclip-prometheus-read ServiceAccount from REN-36.

Reviewed at head 230756c3 in a private worktree. Verified: exactly one object, no Secrets, no write verb, no new ServiceAccount; subject resolves against prometheus-read-rbac.yaml:48-52; name paperclip-cluster-view is unique in the repo. Flux CRD coverage confirmed in git — flux-view-flux-system carries aggregate-to-view: "true" (gotk-components.yaml:240) over all six toolkit groups, so the REN-52 pre-approved extra ClusterRole is correctly not added.

Revertibility confirmed rather than assumed: clusters/homelab/paperclip.yaml:14 is prune: true over ./kubernetes/apps/paperclip and the object has no prune: disabled annotation.

Revert: `git revert <this-merge-sha> -m 1`. Flux deletes the binding within the Kustomization's 10m interval and the token falls back to its REN-36 scope (get services/proxy on two named Services). No workload is touched either way.

Not verified here, owed on REN-111: the post-merge SelfSubjectAccessReview from inside the pod, proving the grant took effect and that view-aggregation actually covers the Flux CRDs in the live cluster.
2026-09-26 03:23:24 +00:00
argus-bot (Paperclip service account)andClaude Opus 5 d1e168ea43 Merge pull request 'fix(monitoring): widen OpenBaoUnavailable for: 1m -> 5m (REN-131)' (#1009) from atlas/ren-131-openbao-unavailable-for into master
Reviewed and merged by argus-bot (REN-134). CI green on 97b7f79b: ci-manifests-test, ci-scripts-test, pr-review.

One semantic line: OpenBaoUnavailable for: 1m -> 5m. Verified against the repo that 1m was the tightest probe window in the cluster (next tightest 5m), that probe and rule evaluation both run at 30s, and that severity=critical routes to the email receiver rather than a pager. Revert: git revert 97b7f79b.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:22:26 +00:00
atlas-botandClaude Opus 5 88010430ad feat(netpol): scope the navidrome monitoring ingress to blackbox-exporter (REN-28)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
navidrome and lidarr both take identity from X-Authentik-Username
unconditionally (navidrome/deployment.yaml), so any pod that can open a TCP
connection to their listeners can claim any username. The baseline CNP
admitted the WHOLE monitoring namespace on EVERY port, which put every
current and future monitoring workload inside that boundary. Only two flows
actually cross it, and both are blackbox probes.

Exposure delta, monitoring -> navidrome namespace:

  before: every pod in `monitoring`, all 4 workloads in `navidrome`, every port
  after:  blackbox-exporter only, :4533 (navidrome) and :8686 (lidarr)

Both selectors and both ports are measured on the live cluster, not inferred:

  * `app.kubernetes.io/name: prometheus-blackbox-exporter` resolves to pod
    blackbox-exporter-prometheus-blackbox-exporter-57844f6f8d-6gqz8 in
    `monitoring` (Prometheus service-discovery pod labels, read through the
    REN-36 read-only proxy grant). Chart prometheus-blackbox-exporter-11.19.1.
  * `probe_success` is 1 for both
    http://navidrome.navidrome.svc.cluster.local:80/ping and
    http://lidarr.navidrome.svc.cluster.local:80/ping. Those Services select
    `app.kubernetes.io/name: navidrome` / `lidarr` and would have no endpoints
    if the pods did not carry the labels, so the probe path is proven end to
    end, prober pod to app pod.
  * Service :80 -> containerPort 4533 (deployment.yaml) and 8686
    (lidarr.yaml). Cilium matches the POD port after service translation, so
    `port: "80"` would read clean in review and break both probes.

ICMP rule added, and it is NOT cosmetic. The toPorts above is the first
layer 4 INGRESS rule in this namespace -- navidrome-ns.yaml is the only CNP
selecting these pods, and its ingress was L3-only until now. Cilium drops
ICMP to an endpoint selected by any layer 4 policy unless the traffic is
CT-related, and the existing ICMP allow matches pods only
(io.kubernetes.pod.namespace Exists), which `host` and `remote-node` do not
carry. The kubelet sources the HTTP probes from the host netns, so its PMTUD
path is host-sourced: without the new rule, Destination Unreachable / Packet
Too Big toward these pods are dropped silently and the symptom is a stalled
transfer, not a policy denial. Same rule, same three types, same reason as
ha-pg-allow-cnpg-operator in home-assistant-ns.yaml. This is an ADDITION to
the approved plan text, flagged here because it is mine, not the plan's.

Two citations elsewhere pointed at the rule being replaced and would have
become false -- the defect REN-28 is about, in reverse. Both now cite what a
rule admits rather than a line number, so they cannot go stale again:

  * navidrome/deployment.yaml -- the union list of what really reaches the pod
  * monitoring-rules/probes-blackbox.yaml -- the justification for why the
    REN-21 probes needed no policy change

NOT touched: `host`/`remote-node` (deleting it CrashLoops both apps -- the
kubelet probes come from there), the in-namespace allow, the Traefik and both
gate-outpost allows, the existing ICMP rules, every egress rule, and
ND_EXTAUTH_TRUSTEDSOURCES (item 2 of REN-28, declined and documented in-tree).
No workload restart: a CNP change is picked up by Cilium in seconds.

Revert: `git revert -m 1 <merge-sha>` on master, through a PR like any other
change. Pure CNP + comment change -- no data migration, no restart, no
rollout; Flux reconciles and Cilium re-applies in seconds. Emergency path if
Flux is impaired:

  kubectl apply -f <(git show <merge-sha>^:kubernetes/infrastructure/network-policies/navidrome-ns.yaml)

Detection that a revert is needed, inside one 30s scrape interval:
`probe_success == 0` on either navidrome target (alerting already exists in
prometheusrule-blackbox.yaml), or either pod leaving Ready.

REN-28 item 3a, approved by the owner. Paperless (item 3b) is a separate PR
and does not open until >=24h of green probes here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:17:56 +00:00
31b1d1170d feat(uptime-monitor): pilot EndpointMonitors for www and audiobooks (REN-7)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The first two of the survey's 14 page rows, and the first objects in this
repo that cause an UptimeRobot write. Both hosts are ungated, both serve a
real application response rather than a gate, and neither has a monitor in
the account today — so the pilot neither duplicates an existing monitor nor
puts anything on the public page that the owner has not already approved.

Uses `spec.url`, not `spec.urlFrom.ingressRef` as REN-7 was written: an
EndpointMonitor can only resolve an ingressRef in its OWN namespace
(pkg/kube/util/url.go:71), `IngressURLSource` has no namespace field, and
every app Ingress lives elsewhere. Reaching them would mean RBAC and a
widened WATCH_NAMESPACE in each production app namespace. Full reasoning in
the file header.

Deliberately omits `uptimeRobotConfig.statusPages`, so that whether these two
monitors appear on uptime.renner.uno on their own answers REN-9's open
question about the page's monitor-list mode.

Also records two source-verified properties that change how this must be
operated, both in comments: a Ready Kustomization cannot prove the monitor
was created (EndpointMonitorStatus is an empty struct, so kstatus returns
Current unconditionally), and v2.2.13 uses no finalizer, so deleting an
EndpointMonitor while the controller is down orphans its monitor.

Verified: kustomize build over all 68 kustomizations, 68 ok / 0 fail; both
rendered EndpointMonitors validated with kubeconform -strict against the
schema extracted from the vendored CRD, with a negative control confirming
the check rejects a bad enum. Lint/render only — NOT applied; Flux applies
from master and these objects do not exist in the cluster yet.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:16:55 +00:00
97b7f79b5f fix(monitoring): widen OpenBaoUnavailable for: 1m -> 5m (REN-131)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
OpenBaoUnavailable went pending at 01:07 UTC on a single 5s HTTPS probe
timeout while OpenBao was healthy, one evaluation short of paging a
critical. At 1m it was the tightest probe-based window in the cluster by
5x, and isolated single-sample probe blips are ambient on the
blackbox-http fleet at roughly one per two hours (three on three
unrelated targets in six hours).

5m matches WoodpeckerDown in the same group. Nothing real is lost: the
rule's own description says existing Secrets keep working and the damage
surfaces at the next ExternalSecret refresh, and OpenBaoQuorumLost
(for: 2m) still covers the fast-moving failure.

expr and severity are unchanged; this only moves the for: window and
replaces the now-wrong "two consecutive failed probes" comment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:10:22 +00:00
atlas-botandClaude Opus 5 5f335847a3 docs(uptime-monitor): give the third ExternalSecret status shape a verdict (REN-106)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comment-only. The narrowing in 16c0c0a7 is unchanged; this records what the
two expressions do with the shape neither of them mentions — an ExternalSecret
with no `Ready` condition.

Review of REN-106 asked for the third state to be made explicit, on the
strength of a metrics census showing `ai/wiki-index-basic-auth` with only a
`Deleted=False` condition. That series is real; the object behind it is not.
ESO v2.11.0 builds the `Deleted` condition on a throwaway object in the
`IsNotFound` branch of Reconcile, purely to retire the object's metric series,
and never persists it — so the series outlives the object until the ESO pod
restarts. That ExternalSecret was retired from git in 419cbed3, its target
Secret is gone from `ai`, and its sync counter is frozen while its six live
siblings advance. On a live object ESO writes conditions of type `Ready` only.

What that leaves is three shapes, all decided: no conditions yet (in-progress,
bounded by `timeout: 5m`), `Ready=True` (current), `Ready=False` (failed for
the two non-retrying reasons, in-progress otherwise). Flux evaluates
inProgress -> failed -> current and falls back to in-progress, so an explicit
`inProgress` expression would be a no-op on all three; the reasoning is written
down instead of encoded twice.

Also noted: `Ready=True` covers reasons `SecretDeleted` and `SecretMissing`,
where ESO reports healthy with no Secret behind it. Unreachable under this
object's default Retain/Owner policies, and a trap for whoever changes them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 03:08:38 +00:00
argus-bot (Paperclip service account) f177685dbd Merge pull request 'docs(uptime-monitor): correct two comments in the IMC manifests (REN-6)' (#1007) from atlas/ren-6-imc-comment-corrections into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed as argus-bot (review 1056) at head 2821629e. Comment-only, reproduced independently in a private worktree: `git diff -U0` against merge-base 64ee49ab has no added or removed line that is not a whole-line `#` comment, and with comment lines stripped both files are md5-identical to master (crd f753baa9c9684002ad01d309e98755ba, rbac 092ddad9c003209d4bd27721c04048b1). Non-comment bytes identical means the parsed YAML is identical, so the applied objects cannot change: no apply, no RBAC change, no restart of the ingressmonitorcontroller pod.

Provenance re-derived from upstream, not taken from the PR body: 2deeba18 is the v2.2.13 tag commit (2026-05-06), aaffd8a8 is a 2-parent master merge commit (2026-09-25), the two chart CRDs are byte-identical, and the vendored body matches upstream at the tag (md5 f753baa9c9684002ad01d309e98755ba over `apiVersion:` to EOF) — so the new header is a check a human can actually run. All seven cmd/main.go + ingress-wrapper.go + url.go line citations in rbac.yaml verified at the tag.

Render check is CI's ci-manifests-test (green at 2821629e, with ci-scripts-test and pr-review; combined state success) — no kustomize in my sandbox either, so I am not claiming a local render.

Revert: `git revert <this-merge-sha> -m 1`. Nothing in the cluster to unwind.

Closes the two follow-ups recorded on the #995 merge (REN-6).
2026-09-26 03:05:11 +00:00
argus-bot (Paperclip service account) b061e3be9f Merge pull request 'docs: repoint seven stale line-number citations (REN-119)' (#1006) from atlas/ren-119-stale-citations into master
Reviewed and merged by argus-bot (REN-124). Comments only: js-yaml loadAll base vs head parsed-identical for all three files; gatus data["config.yaml"] byte- and hash-identical at 6572 bytes, so the gatus Deployment does not roll. All 7 repointed ranges resolved against the target files at 317ddbe9. Revert: git revert 317ddbe9.
2026-09-26 03:04:05 +00:00
argus-bot (Paperclip service account) 10240ae50b Merge pull request 'docs(paperclip): full repo paths for the two manifests behind the proxy grant (REN-47)' (#1005) from atlas/ren-47-manifest-paths into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/wiki-maintenance Pipeline failed
2026-09-26 00:53:23 +00:00
2821629eb1 docs(uptime-monitor): correct two comments in the IMC manifests (REN-6)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both found by Argus in the #995 review and handed to REN-6 as follow-ups.
Comments only — every added and removed line starts with `#`, and both files
are md5-identical to master once comment lines are stripped.

rbac.yaml misstated the failure mode of the omitted pods/services grant. It
claimed a denial "costs a log line, not a reconcile". The function's own error
branches do log and continue, but an RBAC denial never reaches them: the
reconciler holds `mgr.GetClient()` (cmd/main.go:168), so the first Service Get
starts an informer and blocks in cache sync on a `context.TODO()` that nothing
cancels. A forbidden list/watch means the Get never returns and the single
reconcile worker (default `--max-concurrent-reconciles=1`, cmd/main.go:84)
stops entirely, while `/readyz` — `healthz.Ping` — keeps the pod Ready. The
omission is still right (the ingressRef path cannot reach this namespace), but
the grant has to precede an Ingress landing here, not follow the breakage.

crd-endpointmonitors.yaml cited aaffd8a866aa09a77030979d938e766895bde5d4 as the
copy point. That is a master merge commit from 2026-09-25, four months after
the v2.2.13 tag the image is pinned to. Now cites the tag commit
2deeba188fbd2860d0b3a7c3590a954a1a709924 plus the md5 of the vendored body, so
the file can be checked against the version actually deployed. The content was
never wrong: that chart CRD is byte-identical between the tag and the commit
the old header named.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:52:28 +00:00
atlas-botandClaude Opus 5 317ddbe950 docs: repoint seven stale line-number citations (REN-119)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
PR #997 inserted 9 comment lines into `paperless-ns.yaml` and 21 into
`navidrome/deployment.yaml`. The citations that #997 itself wrote were
renumbered; the citations elsewhere in the repo pointing *into* those two
files were not. REN-116 (merged as a8c48b54) then added one more line to
`paperless-ns.yaml`, shifting everything below it by one again.

Seven pointers repointed, all verified against this commit's tree:

  probes-blackbox.yaml:156  navidrome/deployment.yaml   61-74 -> 80-93
  probes-blackbox.yaml:218  paperless-ns.yaml           24-26 -> 34-36
  probes-blackbox.yaml:221  paperless/network-policy    8-12  -> 22-26
  gatus/configmap.yaml:47   paperless-ns.yaml           24-26 -> 34-36
  paperless/network-policy.yaml:15  paperless-ns.yaml   30-32 -> 31-33
  paperless/network-policy.yaml:16  paperless-ns.yaml   33-35 -> 34-36
  paperless/network-policy.yaml:17  paperless-ns.yaml   36-38 -> 37-39

REN-119 specified 34-36 as `33-35` and listed only the first four rows.
Both were correct at `bebf9aad` but are stale after REN-116's one-line
insert, which is above every allow rule in the file. The last three rows
are the same defect from the same commit; leaving them would put the
file back in review for an identical fix.

`paperless-ns.yaml:9-11` (paperless namespace) is above the insert and is
unchanged — verified, not assumed. `navidrome/lidarr.yaml:114-127`,
`lidarr.yaml:29` and `probes-blackbox.yaml:250` were re-checked and are
still correct.

Comments only, and nothing is applied to the cluster. Verified per file:
`js-yaml` loadAll before and after yields identical structures (3 docs,
1 doc, 2 docs), and stripping comment/blank lines from both versions
gives identical text. `gatus/configmap.yaml:47` is in the header above
`data:` (line 62), outside the `config.yaml: |` block scalar, so
ConfigMap `data["config.yaml"]` stays byte-identical at 6572 bytes and
`configmap.reloader.stakater.com/reload: gatus-config` does not roll the
gatus Deployment. All seven edits substitute a range in place; line
counts are unchanged (312/283/97 before and after), so nothing cascades.

Revert: `git revert <this commit>`. Comments only, so a revert restores
the stale numbers and changes no cluster state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:50:26 +00:00
argus-bot (Paperclip service account) 64ee49ab7e Merge pull request 'feat(monitoring): scrape the Flux controllers (REN-97)' (#992) from atlas/ren-97-flux-metrics-alerts into master 2026-09-26 00:48:39 +00:00
atlas-botandClaude Opus 5 4c4501b42b docs(paperclip): full repo paths for the two manifests behind the proxy grant (REN-47)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The proxy-grant paragraph named `prometheus-read-rbac.yaml` and
`helmrelease-prometheus.yaml` by bare filename. Both are now repo-relative
paths, matching how the rest of this page cites manifests, so a 403 at 03:00
does not start with a grep.

Docs-only. No manifest changed. `docs/` is outside every Flux Kustomization
`spec.path` (all of them are under `./kubernetes/`), so Flux renders nothing
from this commit.

Follow-up to Argus's non-blocking note 2 on REN-113/REN-47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:48:11 +00:00
argus-bot (Paperclip service account) d9ccaef89f Merge pull request 'docs(netpol): make the paperless probe citation a resolvable path (REN-116)' (#1004) from atlas/ren-116-probe-citation-path into master
Reviewed-by: argus-bot <svc-argus@renner.uno>

Comments-only. Citation re-verified at head a8c48b54: line 250 of
kubernetes/apps/monitoring-rules/probes-blackbox.yaml is the quoted probe URL,
inside the blackbox-http Probe in namespace monitoring. Both versions of
paperless-ns.yaml parse to identical JSON across all three CiliumNetworkPolicy
documents, so re-applying is a no-op against the running policy.

Revert: git revert -m 1 <this commit>
2026-09-26 00:46:14 +00:00
argus-bot (Paperclip service account) 1c00e8bf6d Merge pull request 'feat(uptime-monitor): deploy IngressMonitorController with zero monitors (REN-6)' (#995) from atlas/ren-6-imc-deploy into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed and merged by Argus (REN-105), review 1038 at c40c337e.

Delete-path bound re-derived from v2.2.13: GetByName exact FriendlyName equality against the default template {{.Name}}-{{.Namespace}}; GetAllByName and uptimerobot GetAll have no reachable callers. Ships zero EndpointMonitors, so the first reconcile makes no UptimeRobot call and the 9 existing monitors are untouched.

CI green at c40c337e (ci-manifests-test, ci-scripts-test, pr-review). Nothing applied to a cluster pre-merge; verification was lint/render only.

Open follow-ups for Atlas on REN-6, both comment-only: rbac.yaml misstates the failure mode for the omitted pods/services grant (a denial blocks the cached client in cache sync and wedges the single reconcile worker while the pod stays Ready, rather than costing a log line), and the CRD vendor header cites a post-tag master commit instead of the v2.2.13 tag.

Revert: git revert -m 1 of this merge commit.
2026-09-26 00:42:20 +00:00
atlas-botandClaude Opus 5 a8c48b54d6 docs(netpol): make the paperless probe citation a resolvable path (REN-116)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`paperless-ns.yaml` cited the blackbox probe target as
`monitoring-rules/probes-blackbox.yaml:250`. From
`kubernetes/infrastructure/network-policies/`, that fragment reads as a
sibling `infrastructure/monitoring-rules/` directory, which does not
exist — `monitoring-rules/` lives only under `kubernetes/apps/`.

Use the full repo-root path, matching the style already used six lines
above for `kubernetes/apps/paperless/network-policy.yaml`. Line 250 and
the probe target itself were correct and are re-verified at this commit:
line 250 is
`- http://paperless.paperless.svc.cluster.local:80/accounts/login/`.

Comments only. Parsing the file before and after with js-yaml yields
identical structures for all three CiliumNetworkPolicy documents, and
stripping comment lines from both versions gives an identical diff. No
`spec` change, no new resource, nothing applied to the cluster.

Follow-up from the REN-110 review of PR #997 (merged as 0995e8d5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:39:57 +00:00
argus-bot (Paperclip service account) bebf9aad0e Merge pull request 'docs(paperclip): record how the read-only proxy grant behaves (REN-47)' (#1000) from atlas/ren-47-proxy-grant-handover-notes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Docs-only. One file, docs/superpowers/specs/2026-09-25-paperclip-design.md, +20/-2.
Closes the four documentation items on the REN-47 review bar (1/14, 5, 7, 15) for the
option-B apiserver-proxy grant, and drops the stale Prometheus-only resourceName text
that REN-95 left behind.

BLAST RADIUS: none. Every Flux Kustomization spec.path in this repo is under
./kubernetes/; docs/ is rendered by nothing. Flux applies nothing from this merge and
no workload restarts. No owner-facing surface changes.

REVERT: git revert %(SHA)s -m 1
One commit, one file, no cluster state to unwind. Nothing to re-apply, no token to
rotate, no pod to restart.

Reviewed by argus-bot (review 1031) against manifests, not against the PR body:
Role is one rule, verbs [get], resources [services/proxy], both resourceNames match
prometheus-read-rbac.yaml on master; chart float >=72.0.0 confirmed in
helmrelease-prometheus.yaml. Authored by atlas-bot, merged by argus-bot.
2026-09-26 00:38:55 +00:00
argus-bot (Paperclip service account) 0995e8d50b Merge pull request 'docs(netpol): correct three comments that assert a boundary Cilium does not enforce (REN-108)' (#997) from atlas/ren-108-trust-boundary-comments into master 2026-09-26 00:37:11 +00:00
atlas-botandClaude Opus 5 230756c35e feat(paperclip): bind the built-in view ClusterRole to the paperclip SA (REN-111)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Implements the owner's decision recorded on REN-52 (2026-09-25, option B),
which was never shipped: one ClusterRoleBinding, roleRef the built-in `view`
ClusterRole, subject the ServiceAccount REN-36 created. No new
ServiceAccount, no new ClusterRole, no write verb, no Secrets.

Kept in its own file rather than appended to prometheus-read-rbac.yaml so it
reverts alone, and because that file's header argues for staying narrow —
which this grant is not. Both of that file's now-false claims are corrected
in place: the token is no longer worth only what its Role grants, and the
`paperclip-prometheus-read` name no longer describes it (REN-52 ruled the
name stays, since `serviceAccountName` is in the pod template and renaming it
would restart the Deployment and evict running agents).

No extra Flux CRD ClusterRole: `flux-view-flux-system` already carries
`rbac.authorization.k8s.io/aggregate-to-view: "true"` with get/list/watch on
`*` across all six Flux API groups, so `view` picks them up by aggregation.
REN-52 pre-approved adding one only if that were absent.

Additive RBAC on an existing ServiceAccount: no Pod restart, no remount, no
traffic path changed. RBAC is evaluated per request, so it takes effect on
the already-mounted token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:37:09 +00:00
c40c337e36 feat(uptime-monitor): deploy IngressMonitorController with zero monitors (REN-6)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Stakater IngressMonitorController in its own namespace, with zero
EndpointMonitor resources, so it creates nothing on arrival.

- Vendored EndpointMonitor CRD from chart 2.2.13, image pinned to v2.2.13
  and deliberately kept out of Flux image automation.
- Namespaced Role only (no ClusterRole): WATCH_NAMESPACE scopes the
  manager's cache to uptime-monitor. Verbs derived from what v2.2.13
  actually calls; every omission is justified inline in rbac.yaml.
- The ExternalSecret now renders IMC's config.yaml, because IMC reads its
  whole configuration from one Secret key rather than an env var. Config
  stays in git; only the key is injected.
- Per-namespace CiliumNetworkPolicy, copied from the netbird-service-sync
  neighbour: kubelet probes in, DNS + kube-apiserver + world:443 out.
- The Flux Kustomization keeps the healthCheckExprs gate and the
  `wait: true` correction that #993 already merged, byte for byte. What
  this commit adds to that file is only what is about REN-6: the
  EndpointMonitor-vs-annotation mechanism in the header, and why
  Deployment health is not a proxy for a current key now that the
  controller shares the Kustomization with the ExternalSecret.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:29:56 +00:00
atlas-botandClaude Opus 5 2b7502b5d3 docs(paperclip): record how the read-only proxy grant behaves (REN-47)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Argus's review bar for the option-B proxy grant (REN-47) asks for three notes on
the hand-over page. None of them was ever written down, and the page had also
gone stale: it still named only the Prometheus resourceName after REN-95 added
Alertmanager.

Added, next to `automountServiceAccountToken: true` where the grant is already
described:

- Both granted resourceNames, not just Prometheus (bar item 1/14).
- The Role scopes the HTTP *method*, not the endpoint — `get` refuses
  `POST /-/reload` in the authorizer, but every GET endpoint of both services is
  reachable, including `/api/v1/status/config` (bar item 5). Verified live this
  run: the six `credentials` fields in that config render as `<secret>`.
- Callers must force GET; Prometheus accepts POST on `/api/v1/query` and that
  form 403s under this Role (bar item 7).
- The drift defence: a 403 means check the Service name against `resourceNames`
  first, because the chart floats at `>=72.0.0` and a rename breaks this path
  silently (bar item 15).

BLAST RADIUS: none. One markdown file under docs/, not rendered by any
Kustomization, so Flux applies nothing and no workload restarts.

REVERT: revert this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:29:42 +00:00
atlas-botandClaude Opus 5 c7d1b1ff4a feat(monitoring): scrape the Flux controllers (REN-97)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adds a PodMonitor for the six Flux controllers in flux-system. Scrape only —
the not-Ready alert rules that were in the first revision of this branch are
removed, because they cannot work at the versions we run.

Why the rules are gone rather than merely deferred: `gotk_reconcile_condition`
and `gotk_suspend_status` are still declared by fluxcd/pkg/runtime v0.110.3
(runtime/metrics/recorder.go), but at the tags pinned in gotk-components.yaml
(source/kustomize v1.9.5, helm v1.6.4, notification v1.9.4, image-reflector and
image-automation v1.2.5) no controller calls RecordReadiness, RecordCondition
or RecordSuspend — zero call sites across all six tagged source trees, only
RecordDuration. An unpopulated GaugeVec exports no series, so those rules would
have evaluated against nothing for ever and the `absent()` dead-man's-switch
would have mailed admin@renner.uno every 12h from 30m after merge. Flux moved
per-object readiness to kube-state-metrics custom-resource-state
(`gotk_resource_info{ready=...}`), which is REN-102 — a prerequisite for the
alert, not a follow-up to it. Caught in review by argus-bot on #992.

What the scrape still buys on its own: `gotk_reconcile_duration_seconds` per
kind/name/namespace, controller-runtime's reconcile/error/workqueue series,
`up{job="monitoring/flux-controllers"}` under the chart's default TargetDown
rule, and the target REN-102 needs in place anyway.

PodMonitor and not ServiceMonitor is forced: four of the six controllers have
no Service, and the two that do publish the artifact/webhook port, not :8080.
No network policy change is needed and that was checked, not assumed —
gotk-components.yaml ships `allow-scraping` admitting every namespace to :8080
and flux-system has no CiliumNetworkPolicy. Nothing in the Flux install is
touched; Prometheus adds a read-only target.

Verified offline only: `kustomize build kubernetes/apps/monitoring-rules` clean
and the PodMonitor renders; all six Deployments carry both selector labels and
the `http-prom` container port. Not applied to the cluster — no kubeconfig in
this account's grants.

Refs REN-97, REN-102.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:28:15 +00:00
atlas-botandClaude Opus 5 16c0c0a7ca fix(uptime-monitor): fail only on the ESO reasons that never retry (REN-106)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The healthCheckExprs block merged in #993 treated any `Ready=False` on the
ExternalSecret as Failed. The `flux-failures` Alert forwards every Kustomization
error event to Alertmanager and Flux health checks have no `for:` window, so a
single unlucky poll mailed the owner about something that was about to fix
itself.

REN-106 asked for `reason == 'SecretSyncedError'`, matching wordpress.yaml.
Checked against the ESO the cluster runs (v2.11.0) that is the wrong reason:
`SecretSyncedError` is the reason ESO sets when it returns an error and keeps
retrying — an OpenBao blip, a token renewal and a not-yet-populated key all
produce it. `SecretOwnedByOther` and `SecretImmutable` are the two reasons ESO
returns nil on, i.e. where it has stopped retrying.

So `failed` now names those two, and `SecretSyncedError` falls through to
in-progress and fails the Kustomization at the existing `timeout: 5m`, which
becomes the missing `for:` window. Detection of a genuinely stuck sync moves
from immediate to ~5m; a transient blip no longer reaches the owner at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:27:54 +00:00
a557eea7bb docs(netpol): correct three comments that assert a boundary Cilium does not enforce (REN-108)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comments only. No `spec` change, no pod template change, no rendered
manifest change.

Three comments told the reader the ingress side of paperless and navidrome
is locked to the Authentik gate outposts. It is not. `default-deny-ingress`
for both namespaces uses `endpointSelector: {}`, so it selects the app pods
too, and Cilium unions the allow rules of every CNP selecting an endpoint.
The effective ingress set is therefore the outpost PLUS whole namespaces:
`database` and `monitoring` for paperless, and `monitoring` plus the
lidarr-gate outpost for navidrome. Because both apps trust
X-Authentik-Username unconditionally, any pod in those namespaces can claim
any username.

- kubernetes/apps/paperless/network-policy.yaml: replace "Lock the ingress
  side to the gate outpost only" with what is actually enforced, citing the
  rules in paperless-ns.yaml.
- kubernetes/infrastructure/network-policies/paperless-ns.yaml: note that
  the database and monitoring allows below do broaden the trust boundary in
  exactly the way the traefik note above warns about, and why they are kept
  (CNPG, and the blackbox probe of the paperless login page).
- kubernetes/apps/navidrome/deployment.yaml: ND_EXTAUTH_TRUSTEDSOURCES is
  the whole pod CIDR and is not a narrowing; record why it stays that way
  (the outpost pod IP is not stable) and list what the CNP really admits.

Verification: `kubectl kustomize` (v1.31.0 / kustomize v5.4.2) over all 68
kustomization roots in the repo renders byte-identical before and after;
every added and removed line in the diff is a comment line.

Revert: `git revert <this commit>`; restores master 3d6c9940 exactly.

Refs REN-108, REN-28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-26 00:25:56 +00:00
argus-bot (Paperclip service account) 3d6c9940d6 Merge pull request 'fix(uptime-monitor): make wait: true actually catch a failed ESO sync (REN-100)' (#993) from atlas/ren-100-externalsecret-healthcheck into master
ci/woodpecker/pr/ci-manifests-test Pipeline is pending
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/pr-review Pipeline is pending
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-26 00:16:05 +00:00
argus-bot (Paperclip service account) 6b8f50a4ac Merge pull request 'feat(monitoring): scrape the ESO controller so a failed sync is visible (REN-103)' (#994) from atlas/ren-103-eso-scrape into master 2026-09-26 00:15:59 +00:00
argus-bot (Paperclip service account) 8313d50b6d Merge PR #987: make the lidarr and tika ImagePolicy sort keys a total order (REN-91)
ci/woodpecker/cron/alert-monitor Pipeline was successful
Make the lidarr and tika ImagePolicy sort keys a total order, and undo the
automated ls42 -> ls11 downgrade that the defective lidarr key caused.

Reviewed and merged by argus-bot; authored by atlas-bot (four-eyes).
Pod roll of deploy/lidarr in ns navidrome signed off by Zeus on REN-91.

Revert: policy only `git revert e50ccaec`; pin only `git revert 430475a7`;
everything `git revert 430475a7 e50ccaec` (restores master ff4ddaa6 exactly).

Refs REN-91.
2026-09-26 00:15:26 +00:00
atlas-botandClaude Opus 5 59327cfa85 feat(monitoring): scrape the ESO controller so a failed sync is visible (REN-103)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing in this cluster reported a failed ExternalSecret sync:
`externalsecret_status_condition` held 0 series, ESO's metrics port was never
scraped, and monitoring-rules had no ESO alert. A key that silently stops
syncing was invisible to Prometheus and to Alertmanager.

This is the scrape only. The alert rule is deliberately left out: its `for:`
window has to be tuned against real data, and that data does not exist until
this has been live for a while. Shipping a guessed window to the owner's
channel is the failure mode REN-100 warned about.

A PodMonitor rather than the chart's `serviceMonitor.enabled`, for two reasons
checked against chart 2.11.0:

  * There is no metrics Service to select. `metrics.service.enabled` defaults
    to false and the HelmRelease overrides nothing, so templates/service.yaml
    renders nothing for the controller. A hand-written ServiceMonitor would
    match no Service.
  * `serviceMonitor.enabled: true` would work but triggers a Helm upgrade of
    ESO, re-rendering its Deployment and re-applying its CRDs
    (`crds: CreateReplace`). ESO is what all 54 ExternalSecret manifests in
    this repo depend on. A PodMonitor gets the same series and touches the
    release not at all.

Unlike the Flux PodMonitor, this needs a network policy change: the cluster is
default-deny and external-secrets-ns.yaml admitted only intra-namespace
traffic, host/remote-node and ICMP. Without the `monitoring` grant the scrape
is dropped and the metric stays absent, so the PodMonitor alone would have been
decoration. The grant is port-restricted to 8080, tighter than the equivalent
rule in velero-ns.yaml, and is additive — it removes nothing, so ESO's existing
reachability to OpenBao and the apiserver is unchanged.

Revert: `git revert <sha>`. Removes the PodMonitor and the ingress grant
together, returning to the current no-ESO-metrics state. No ESO object, no
ExternalSecret and no Secret is touched in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:08:09 +00:00
atlas-botandClaude Opus 5 69fef47b09 fix(uptime-monitor): make wait: true actually catch a failed ESO sync (REN-100)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The comment in this file claimed `wait: true` was how a key that failed to
sync became visible. It is not. Flux ships no status reader for
`ExternalSecret`, so kstatus falls back to its generic rule, which inspects
only `status.observedGeneration` and conditions of type `Reconciling` /
`Stalled`. ESO sets none of those — a failed sync is `Ready=False,
reason=SecretSyncedError` — so kstatus returns Current and the Kustomization
goes Ready with no Secret behind it.

Corroborated in-repo rather than from docs: `llama-server` is also `wait: true`
and carries `ai/wiki-embed-basic-auth`, which sat in ESO retry backoff through
~10 failures on 2026-09-20 and needed a manual `force-sync` while its
Kustomization stayed Ready.

Adds a `healthCheckExprs` entry for `external-secrets.io/v1 ExternalSecret`,
the same mechanism already used here for CNPG `Cluster`, MariaDB and the Ceph
CRs, and corrects the comment.

Both expressions are guarded with `has(status.conditions)`, copying
wordpress.yaml. Unguarded, CEL errors rather than evaluating while `status` is
still empty — the state a freshly applied ExternalSecret is in, which is
precisely the case this check exists for.

Blast radius is this Kustomization alone: nothing `dependsOn` uptime-monitor,
and its only object today is the ExternalSecret itself. No existing workload,
ingress controller, DNS record or other Kustomization is touched.

Revert: `git revert <sha>`, or drop the `healthCheckExprs` block. Reverting
restores the previous behaviour (Ready regardless of sync state) and cannot
affect any other Kustomization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:02:09 +00:00
argus-bot (Paperclip service account) ff4ddaa6a2 Merge PR #990: let the read-only proxy grant reach Alertmanager (REN-95)
ci/woodpecker/cron/alert-monitor Pipeline was successful
Appends kube-prometheus-stack-alertmanager:9093 to the resourceNames of
Role paperclip-prometheus-read in namespace monitoring. Additive, get-only,
no pod template change.

Reviewed and merged by argus-bot; authored by atlas-bot (four-eyes).
Negative baseline reproduced live via SelfSubjectAccessReview before merge.
2026-09-25 23:54:25 +00:00
argus-bot (Paperclip service account)andPaperclip bdebf6f8ef Merge PR #988: UptimeRobot API key from OpenBao via ESO (REN-5)
Makes the UptimeRobot API key available in the new `uptime-monitor` namespace via ESO. No controller, no monitors, no workload — the key only. First step of REN-2, closes REN-5.

Reviewed by argus-bot against a fresh clone at 25cabaed, not against the PR body. Verified: remoteRef key is mount-relative (store sets provider.vault.path: homelab; `grep -rn "key: homelab/"` = 0 hits); eso-read.hcl already grants homelab/data/*; allowlist is 20 entries, sorted, one occurrence; uptime-monitor is absent from apps/kustomization.yaml and clusters/homelab has no kustomization.yaml, so no double-application; both dependsOn targets exist in flux-system; no duplicate namespace name across the 31 declared; no key material in the diff.

Blast radius is one leaf Kustomization that nothing dependsOn -- deliberately NOT added to infrastructure/post-crds, where an unpopulated OpenBao path would stall the 19 Kustomizations that dependsOn infrastructure-crds.

Merge commit on purpose so the documented revert stays valid: git revert 25cabaed. prune: true then removes the Kustomization, ExternalSecret, Secret and namespace.

Non-blocking finding handed to REN-6: the comment claiming `wait: true` makes a failed ExternalSecret sync visible is wrong -- kstatus reports an ESO ExternalSecret Current regardless of Ready=False, which is why the `ai` ExternalSecret under the wait:true llama-server KS could sit in backoff through ~10 failures on 2026-09-20. Fix is a healthCheckExprs block, as already used for CNPG and Ceph CRs here.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 23:53:44 +00:00
AtlasandClaude Opus 5 430475a79a fix(lidarr): undo the automated downgrade to ls11 (REN-91)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Reverts the image change in b858b4aa. ls11 is 3.1.0.4875 as built on
2025-11-19; ls42 is the same upstream build rebuilt on 2026-09-23. The
31 rebuilds in between are LinuxServer security rebuilds of the base
image, so the cluster is currently running ten months of known-missing
base-image patches.

Kept as its own commit so it can be reverted independently of the policy
change, and done here rather than left to the controller: with the fixed
policy merged, image-automation would compute ls42 and push the same two
lines to master itself within ~30 min. Doing it in the PR makes the pod
roll a reviewed change that lands at a known time instead of an
unattended bot commit -- which is the failure mode this card is about.

Blast radius: one rolling restart of deploy/lidarr in ns navidrome, same
shape as the 60s outage b858b4aa caused. Lidarr is a read-only
missing-album view; nothing else consumes it. No other workload moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 23:51:25 +00:00
AtlasandClaude Opus 5 e50ccaecba fix(image-automation): make lidarr and tika sort keys a total order (REN-91)
lidarr extracted only <build> from <maj>.<min>.<patch>.<build>-ls<n>, so
all 32 published ls rebuilds of build 4875 shared one sort key and the
winner was an arbitrary tie-break. It fired: b858b4aa auto-committed
ls42 -> ls11 to master and rolled the production pod.

numerical cannot order two fields and alphabetical over a concatenation
mis-sorts on width (ls9 vs ls11), so the key becomes a synthetic semver
<build>.<ls>.0 under a semver policy -- width-safe, and equivalent to
(build, ls) lexicographic. The Lidarr major moves into filterTags
because the range now operates on the synthetic key; a 4.x bump needs a
deliberate edit, per the tight-range rule in README.md.

tika has the same defect: extracting <maj>.<min>.<patch> from a
4-component tag discards the image revision. The README called this
theoretical; it is not -- 2.5.0.1/2.5.0.2, 2.6.0.0/2.6.0.1,
2.7.0.0/2.7.0.1 and 2.9.2.0/2.9.2.1 all collide. Every 3.x release so
far is .0, so the revision is pinned in the filter the way
protonmail-bridge.yaml pins its -1 suffix. No-op today; if upstream
ships a 3.x .1 the policy stops bumping instead of picking wrongly.

Verified against the real published tag lists rather than by inspection:
over 1000 lidarr tags the new filter matches 34, produces 34 distinct
keys, has zero publish-order inversions across the visible history, and
selects 3.1.0.4875-ls42. tika reduces to one tag per key and still
selects 3.3.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 23:51:25 +00:00
6fd0bcf1d7 feat(paperclip): let the read-only proxy grant reach Alertmanager (REN-95)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Appends one `resourceNames` entry to the existing `paperclip-prometheus-read`
Role so the read-only apiserver service-proxy path also covers Alertmanager:

    kube-prometheus-stack-alertmanager:9093

This is the widening that the file's own header comment said required a new
card and a new review — REN-94/REN-95 are that card, and Hermes confirmed the
target against the live cluster before this was written.

Nothing else about the grant changes. Same single rule, same `verbs: ["get"]`,
same `resources: ["services/proxy"]`, same ServiceAccount, same RoleBinding.
No new rule, no cluster-scoped kind, no second verb. `get`-only still means the
authorizer refuses POST/PUT to either target, so Alertmanager's silence and
`/-/reload` write paths stay denied for the same structural reason Prometheus's
`/-/quit` does.

No NetworkPolicy edit: `network-policy.yaml` already allows egress to entity
`kube-apiserver` on 6443, and the Alertmanager leg of the proxy hop originates
at the apiserver, not at the paperclip pod.

Unlike REN-36 this touches a Role only, not the pod template, so Flux applies
it without restarting the paperclip Deployment and without evicting any agent.

Comment updates are included because the header asserted "ONE VERB, ONE
RESOURCE NAME" and named only the Prometheus proxy URL; both would have become
false next to a second name.

Revert: revert this one commit. The grant returns to Prometheus-only and no
workload restarts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 23:46:07 +00:00
25cabaed76 feat(uptime-monitor): sync the UptimeRobot API key from OpenBao via ESO
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Makes the key at OpenBao `infrastructure/uptime-robot`, field `api_key`,
available as a Secret in the new `uptime-monitor` namespace and nowhere
else. No new operator: this uses the External Secrets Operator and the
`openbao` ClusterSecretStore the cluster already runs.

Nothing is monitored by this commit. The namespace holds only the key;
the IngressMonitorController, its ServiceAccount and its RBAC arrive
separately (REN-6).

* `data` + `property` instead of `dataFrom: extract`, so only the one
  field lands in the cluster rather than every field at the KV path.
* Adds `uptime-monitor` to the ClusterSecretStore `conditions.namespaces`
  allowlist, sorted. Without this ESO reports "not allowed to reference
  ClusterSecretStore" and the Secret silently never syncs.
* A DEDICATED Flux Kustomization rather than adding the ExternalSecret to
  infrastructure/post-crds. That path runs `wait: true` with no explicit
  healthChecks and 19 other Kustomizations dependOn it, so an
  ExternalSecret whose OpenBao path is not yet populated would never turn
  Ready and would stall most of the cluster. Here the blast radius of a
  missing key is this Kustomization alone, which nothing depends on.
* No OpenBao policy change: eso-read already grants `homelab/data/*`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 23:40:24 +00:00
argus-bot (Paperclip service account)andPaperclip a833a9083a Merge PR #986: exclude EC pools from CephPoolLostRedundancy (REN-88)
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Stops a random critical false page on the storage layer. The rule inferred a replication factor from bytes_used/stored, which for an ec:2+1 pool is coding overhead of exactly 1.5 -- the rule's own threshold -- so pool 15 cephfs-bulk paged for 199 minutes on 2026-09-25 with the cluster HEALTH_OK.

Reviewed by argus-bot against live Prometheus using the expression parsed from e3c5c4c0. Fire-window replay: old expr 231 samples on pool 15, new expr 0. Positive control at threshold <100: old selects pools {1,3,5,6,13,15}, new selects {1,3,5,6,13} -- exactly one pool removed and it is the EC one. pool_id survives the join so the annotations still render.

Merge commit on purpose so the documented revert stays valid: git revert e3c5c4c0.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 23:30:56 +00:00
e3c5c4c0e6 fix(monitoring): exclude EC pools from CephPoolLostRedundancy (REN-88)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
CephPoolLostRedundancy infers a replication factor from
ceph_pool_bytes_used/ceph_pool_stored and pages critical below 1.5. For an
erasure-coded pool that ratio is not replication, it is coding overhead:
(k+m)/k. Both EC pools in this cluster are ec:2+1, so their correct,
healthy value is exactly 3/2 = 1.5 — the rule's own threshold.

cephfs-bulk (pool 15) reads 480042086400/320028049408 = 1.5000000384. The
margin is 12 KiB in 480 GB, so the comparison is decided by the last bits
of a float division and lands on either side at random. It paged critical
for 199 minutes on 2026-09-25 (12:37-15:56 UTC, two emails) while the
cluster was HEALTH_OK with zero degraded PGs and every OSD up, then cleared
untouched. migration.rgw.buckets.data (pool 12) is the same ec:2+1 shape.

The rule's premise is also wrong for EC: its description claimed a single
OSD loss destroys data, but 2+1 carries a parity shard and tolerates one
OSD loss, the same as replica-2. Summary and description now say
"replicated" and drop that claim.

Gated on the exporter's own `type` label rather than moving the threshold.
Nudging 1.5 to 1.49 would keep a knife-edge and would also narrow the real
replica-2 -> replica-1 signal (1.0) this rule exists to catch.

NO COVERAGE GAP: a genuine EC redundancy loss raises POOL_NO_REDUNDANCY,
which is a live series in ceph_health_detail and is already deliberately in
scope for CephHealthWarn. That reads Ceph's own verdict instead of
inferring one from bytes, which is the right instrument — an EC pool that
loses a shard does not move its stored/used ratio at all.

VERIFIED against live Prometheus (read-only apiserver proxy, REN-36), using
the expression as serialized from this file:
  * 7d replay, old expr: pool 15 matches 49 samples (defect reproduced)
  * 7d replay, new expr: 0 matches
  * fire-window replay (12:00-16:30 UTC): old matches pool 15 at 231/271
    steps, new matches nothing
  * positive control, new expr with threshold raised 1.5->3.5: selects
    replicated pools 1,3,5,6,7,13,14 and excludes both EC pools (12,15) —
    the filter removes exactly the EC pools and nothing else
  * evaluated cleanly across 17 distinct mgr instances over 30d, so the
    on(pool_id) join does not break on mgr failover

BLAST RADIUS: none to workloads. This edits one PrometheusRule; Prometheus
hot-reloads rule files and no pod restarts. Worst case is alerting scope on
one rule. Nothing writes to the cluster, no Ceph object is touched.

REVERT: revert this commit. One file, no state left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 23:18:58 +00:00
argus-bot (Paperclip service account) f8fc0c8f1b Merge pull request 'docs(paperclip): record why the paperclip KS has no monitoring dependsOn edge (REN-86)' (#985) from atlas/ren-86-paperclip-dependson-monitoring into master 2026-09-25 23:17:55 +00:00
atlas-botandClaude Opus 5 1de7071ea9 docs(paperclip): record why this KS has no monitoring edge (REN-86)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
REN-86 asked for one field: `- name: monitoring` on the `paperclip`
Kustomization's dependsOn, because apps/paperclip/prometheus-read-rbac.yaml
(PR #984) puts a Role and a RoleBinding in namespace `monitoring` and a
from-scratch bootstrap was expected to fail applying them.

The premise does not hold, so the field is not added and the reasoning is
written down instead:

- Namespace `monitoring` is not created by the `monitoring` Kustomization. It
  is declared in kubernetes/infrastructure/namespaces/namespaces.yaml, applied
  by `infrastructure`, which this Kustomization already dependsOn.
- `infrastructure` runs wait: false, so it applies the namespaces on its first
  pass and only its healthChecks gate readiness. It cannot report Ready before
  the namespace exists, and this Kustomization cannot start before that.
- Role and RoleBinding are core rbac.authorization.k8s.io/v1 kinds, and a
  RoleBinding may reference a Role that does not exist yet. Nothing on this
  path needs a kube-prometheus-stack CRD or workload, so there is no analogue
  of the monitoring-rules/PrometheusRule split.

Adding the edge would also couple the Kustomization that carries the paperclip
pod to `monitoring` readiness, which itself waits on grafana-db,
kube-prometheus-stack, Loki, Alloy and blackbox-exporter for up to 30m.

Comments only: the parsed manifest is identical to master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 23:09:27 +00:00
argus-bot (Paperclip service account) d3e759112f Merge PR #984: read-only Prometheus path via apiserver proxy + RBAC get (REN-36)
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Option B on REN-37, owner-approved: reach Prometheus through the apiserver
service proxy under a Role holding one verb, instead of an L4 allow on :9090
that could not tell GET /api/v1/query from the unauthenticated POST /-/quit
that --web.enable-lifecycle puts on the same port.

  ServiceAccount  paperclip/paperclip-prometheus-read
  Role            monitoring/paperclip-prometheus-read
                  get on services/proxy, resourceNames:
                  ["kube-prometheus-stack-prometheus:9090"]
  RoleBinding     monitoring/paperclip-prometheus-read -> that SA
                  (subject namespace: paperclip - load-bearing, cross-ns)
  paperclip.yaml  serviceAccountName + automountServiceAccountToken: true

Authored by svc-atlas, reviewed and merged by argus-bot: author is not the
merger. ci-manifests-test green on 35bcdf10 (kustomize build over the whole
tree). Reviewed against master 3a457bd6 in a fresh clone.

BLAST RADIUS: applying this restarts the paperclip pod (strategy: Recreate)
and every agent running in it. Nothing in production monitoring restarts -
Prometheus, Alertmanager, Loki and the ingress controllers are untouched.
The paperclip Flux Kustomization has wait: true, so a pod that does not come
back shows as a failed reconcile rather than silently.

REVERT - one step:

  git revert -m 1 <the sha of this merge commit>

That drops serviceAccountName, restores automountServiceAccountToken: false
and deletes prometheus-read-rbac.yaml; Flux prunes the ServiceAccount, Role
and RoleBinding on the next reconcile (paperclip Kustomization, prune: true).
It restarts the paperclip pod a second time, for the same reason the apply
does.

AGENT-FREE ESCAPE HATCH - use this if the pod does not come back, because
then there is no agent left to run the revert above. Flux re-applies the same
spec every 10m and overwrites a bare rollout undo; the suspend is what makes
the undo stick:

  flux -n flux-system suspend kustomization paperclip
  kubectl -n paperclip rollout undo deployment/paperclip
  # then land the git revert above, and afterwards:
  flux -n flux-system resume kustomization paperclip

Names as they are in this repo: `paperclip` is both the Flux Kustomization in
flux-system (clusters/homelab/paperclip.yaml) and the Deployment in namespace
paperclip. No revisionHistoryLimit is set anywhere under kubernetes/, so the
default 10 holds and a previous ReplicaSet exists to undo to. The undo
restores automountServiceAccountToken: false, which closes the read path at
once, before the RBAC objects are pruned. It rolls the pod template back
wholesale, so an image bump that landed after this merge is rolled back with
it - re-apply that after the resume.

Escape hatch text is the merger's, written into this merge commit because
merge condition 2 puts the revert recipe here. The diff is untouched.

Refs: REN-36, REN-37 (option B), REN-47 (review bar), REN-73 (merge card)
2026-09-25 21:41:40 +00:00
atlas-botandClaude Opus 5 35bcdf1043 feat(paperclip): read-only Prometheus path via apiserver proxy (REN-36)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner picked option B on REN-37: reach Prometheus through the apiserver
service proxy under an RBAC Role holding one verb, instead of an L4 Cilium
allow on :9090.

The port cannot be the boundary. The chart leaves `reloadStrategy` unset, so
the operator defaults it to HTTP and appends `--web.enable-lifecycle`, which
puts `POST /-/quit` and `POST /-/reload` on :9090 with no auth in front of
them. An L4 allow cannot distinguish those from `GET /api/v1/query`. The
apiserver can: it maps HTTP method to RBAC verb, so a Role with only `get`
refuses both in the authorizer before the request reaches Prometheus.

  ServiceAccount  paperclip/paperclip-prometheus-read
  Role            monitoring/paperclip-prometheus-read
                  get on services/proxy, resourceNames:
                  ["kube-prometheus-stack-prometheus:9090"]
  RoleBinding     monitoring/paperclip-prometheus-read -> the SA above
  paperclip.yaml  serviceAccountName + automountServiceAccountToken: true

Shape copied from monitoring/ci-diagnostics-rbac.yaml, which has run this
same proxy path for the alert-monitor since 2026-07-16. No network-policy
edit: paperclip-egress already allows kube-apiserver on 6443.

BLAST RADIUS: applying this restarts the paperclip pod, and every agent with
it. Nothing in production monitoring restarts — Prometheus, Alertmanager,
Loki and the ingress controllers are untouched.

REVERT: revert this commit. One commit, four files, no state left behind;
Flux prunes the SA, Role and RoleBinding and rolls the pod back to no
mounted token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:30:04 +00:00
argus-bot (Paperclip service account) 3a457bd63c Merge PR #978: record the egress dependency and the real revert recipe for the app-health probes (REN-21)
ci/woodpecker/cron/alert-monitor Pipeline was successful
Comment-only change to kubernetes/apps/monitoring-rules/probes-blackbox.yaml. Reviewed by argus-bot at head 49bd9271 against base 2b71dda1: 3 documents parse deep-equal, blackbox-http 20 targets before and after, git diff -U0 non-comment non-blank lines = 0, apps/gatus/configmap.yaml untouched. Revert recipe and blast-radius claims re-executed and confirmed.
2026-09-25 21:13:11 +00:00
atlas-botandClaude Opus 5 49bd927147 docs(monitoring): egress dependency, blast radius, tested revert recipe (REN-21)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comment-only. Rebuilt on top of master (2b71dda1) after #979 merged, so it
adds to that text instead of reverting it.

- EGRESS: names the two ingress-only Cilium policies in the monitoring
  namespace and where blackbox-exporter runs; counts re-verified at
  2b71dda1 (635 yaml files, 73 Cilium objects, 38 with egress/egressDeny,
  0 in monitoring, 1 CCNP scoped to reserved.health, 17 namespaces with
  an egress block).
- BLAST RADIUS: what breaks if that dependency is ever broken
  (BlackboxProbeFailing x3, BlackboxProbesAbsent stays silent), who
  notices (admin@renner.uno, ~10-11 min), and how fast it is undone.
- REVERT: replaced the claim with an executed recipe. All four git revert
  candidates CONFLICT at 2b71dda1; the two forward sed deletions were run
  and verified (blackbox-http 20 -> 17 and 20 -> 19).

No object changes: 3 documents deep-equal before/after under two
independent parsers, blackbox-http still 20 targets, and `git diff -U0`
has zero non-comment non-blank changed lines. apps/gatus/configmap.yaml
is not touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:06:51 +00:00
argus-bot (Paperclip service account)andPaperclip 2b71dda105 Merge PR #982: image-automation push identity is the flux account (REN-50)
ci/woodpecker/cron/alert-monitor Pipeline was successful
Comment-only change to kubernetes/infrastructure/image-automation/update-automation.yaml.

Reviewed by Argus (svc-argus/argus-bot). Verified comment-only: SHA256 of the comment-stripped file is 4ed11c66f18b8d15 before and after. No object field changes, so Flux's next reconcile is a no-op.

Revert: git revert 4f1c2cf1

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 20:55:28 +00:00
atlas-bot (Paperclip service account)andPaperclip 4f1c2cf174 docs(image-automation): push identity is the flux account, not gitea_admin
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The comment on ImageUpdateAutomation/homelab named gitea_admin as the
owner of the bootstrap token that pushes writeback commits to master.
That is wrong. The pushing account is `flux` (Gitea uid 9): its
contribution history matches every `chore(image-automation)` commit on
master 1:1, while gitea_admin's own contributions stop at 2026-07-21.

Also separates two things the old wording ran together — the commit
author (security-bot@renner.uno, set in this spec) and the pushing
account (the token owner, not set here) — and records that writeback
failure is silent, since no commit is attempted unless a tracked image
moves.

Comment-only. The ImageUpdateAutomation object is byte-identical; no
Kubernetes object changes, nothing for Flux to reconcile.

Refs REN-50

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 20:44:53 +00:00
security-bot b858b4aa4f chore(image-automation): bump lscr.io/linuxserver/lidarr:3.1.0.4875-ls11
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-25 20:38:54 +00:00
argus-bot (Paperclip service account) 46b32d4ec3 Merge pull request 'docs(monitoring): correct the CNP reasoning, record the egress dependency (REN-21)' (#979) from docs/probe-cnp-egress-and-status-caveats into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Comment-only. No Kubernetes object changes on either file: all 3 probe documents deep-equal to master (blackbox-http 20 targets), ConfigMap/gatus-config deep-equal with data["config.yaml"] byte-identical at sha256 8886ef21...6472 (6572 bytes), so Reloader does not roll gatus. Non-comment non-blank changed lines vs merge base 0a82d61f: 0. Reviewed and verified by argus-bot at head 9142b78a; the REVERT recipe was re-tested against its own commit.
2026-09-25 20:01:41 +00:00
k.renner fd7b4f2eff Merge pull request 'fix(paperclip): raise memory limit to 32Gi (OOMKilled)' (#980) from fix/paperclip-memory into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#980
2026-09-25 19:50:42 +00:00
claude-botandClaude Opus 5.5 a412ae4fb7 fix(paperclip): memory limit 32Gi
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Headroom for many concurrent agents in the one container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 21:49:57 +02:00
claude-botandClaude Opus 5.5 9319943f64 fix(paperclip): raise memory limit; agents run inside the container
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The container was OOMKilled (exit 137) at 19:43 under the 4Gi limit.
With server-local runs every concurrent agent's Claude Code process
and MCP helpers live in this container, so the limit caps how many
agents can work at once; exceeding it kills the server and every
in-flight run (process_lost / orphaned_running_run).

Limit 16Gi, request 2Gi (idle server ~1Gi). Nodes have ~93Gi
allocatable each at 12-49% use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 21:49:09 +02:00
9142b78a18 docs(monitoring): fix the REVERT sentence this PR itself falsifies (REN-21)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comment-only, one sentence, one file. Wording is Argus's from the #979
review (REN-38).

The REVERT paragraph ended with "The paperless block below DOES revert
cleanly on its own with `git revert -m 1 0a82d61f`". True at 0a82d61f,
false at bd8690eb, because this same PR edits the paperless block:

  from 0a82d61f:  revert -m 1 0a82d61f -> CLEAN     revert -m 1 16238d39 -> CONFLICT
  from bd8690eb:  revert -m 1 0a82d61f -> CONFLICT  revert -m 1 16238d39 -> CONFLICT

Reproduced here: at bd8690eb the collision is one region, `<<<<<<< HEAD`
at 155 through `>>>>>>>` at 198, the whole paperless block against an
empty side. So the advice failed in exactly the case it was written for
— someone reverting the paperless probe under pressure, four lines below
a warning not to trust the natural resolution on this file. The new text
says what actually happens and how to resolve it, and stays true as the
comment block keeps changing, because it no longer promises clean.

Bar re-verified at this commit, both files against master, js-yaml:

  probes-blackbox.yaml   docs base=3 head=3, all deep-equal=true
                         http_2xx/20, http_2xx_wan_edge/3, tcp_connect/2
  gatus/configmap.yaml   ConfigMap/gatus-config deep-equal=true
                         data["config.yaml"] sha256 8886ef21…6472 both
                         sides, identical=true, bytes=6572
  git diff -U0 vs master, non-comment non-blank lines: 0

apps/gatus/configmap.yaml is not touched by this commit at all.

Production constraint: nothing applied, restarted or re-rendered. The
public status.renner.uno page does not roll — ConfigMap data is
unchanged, so configmap.reloader.stakater.com/reload has nothing to act
on.

Revert: `git revert <this-sha>` restores the previous sentence and
nothing else.

Refs REN-21, REN-38, REN-44.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:40:06 +00:00
svc-atlasandPaperclip bd8690ebdc docs(monitoring): correct the CNP reasoning, record the egress dependency (REN-21)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comment-only. Every parsed object in both files is byte-identical to
master at 0a82d61f, verified with js-yaml; zero non-comment lines change.

Review follow-ups from REN-22 on the merged #976/#977 probes:

1. The CNP argument was right in its conclusion and wrong in its framing.
   cnp-cross-namespace-allow-apps-gatus-to-monitored-services DOES apply to
   blackbox-exporter — its Generalization section names it. We are safe
   because the allow it demands is already present, not because the note is
   about gatus. The old wording is the sentence that makes the next person
   skip the check for the next namespace.

2. Records the load-bearing egress fact, which was written down nowhere.
   Cilium enforces at both ends; blackbox-exporter can send only because no
   policy selecting a monitoring endpoint declares an `egress:` block.
   Verified across the repo: 73 Cilium policies, 38 with egress, none in
   monitoring, and the one CCNP is scoped to `reserved.health`. 17 other
   namespaces already have an egress block, so a routine hardening pass on
   monitoring would break these three targets and leave the *.monitoring.svc
   ones working.

3. Extends the 200-vs-3xx caveat to all three new targets, not just
   paperless. The repo evidence behind every one of them establishes only
   `< 400` (kubelet httpGet accepts 200-399; lidarr.yaml:29's `curl -f`
   without -L exits 0 on a 302). It proves the paths are ungated, not that
   they answer 200. A 3xx gets reverted, not widened.

4. Records the tested revert recipe for #976 in the file. `git revert -m 1
   16238d39` conflicts and the natural resolution silently drops paperless
   too.

5. Corrects the stale "needs a CiliumNetworkPolicy first" claim in
   apps/gatus/configmap.yaml. The correction is written in the manifest
   header, ABOVE `data:`, not in place: the stale sentence sits inside the
   `config.yaml: |` block scalar, and gatus carries
   configmap.reloader.stakater.com/reload, so editing it in place would roll
   the gatus Deployment for a comment. ConfigMap data sha256 is unchanged.

Production constraint: nothing is applied, restarted or re-rendered by this
change. No workload, namespace, policy or probe target is touched.

Revert: `git revert -m 1 <merge-sha>`, or drop either file alone with
`git checkout 0a82d61f -- <path>`.

Refs REN-21, REN-22.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 19:18:37 +00:00
argus-bot (Paperclip service account)andClaude Opus 5 0a82d61ff8 Merge PR #977: in-cluster app-health probe for paperless (REN-21)
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).
Rebased onto master after #976 by argus-bot; conflict markers only were removed,
the 29 added lines are byte-identical to the original commit bdf55c5b.
blackbox-http 19 -> 20 targets; other two Probe documents unchanged.
Revert: git revert -m 1 <this merge sha> is clean (tested; 20 -> 19, navidrome
and lidarr intact).
Open risk: /accounts/login/ must return 200, not 302 -- module http_2xx passes
only a final 2xx. If it is a 302, revert this PR rather than widening
valid_status_codes. Unverified at merge time: no Prometheus read path. See REN-22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:52:17 +00:00
svc-atlasandPaperclip cb9e73bd94 feat(monitoring): in-cluster app-health probe for paperless
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
paperless.renner.uno is served entirely through the paperless-gate
Authentik outpost. gatus already records the consequence at
apps/gatus/configmap.yaml:176-185: every path including /api/ answers
302 to an unauthenticated caller, so its check of that hostname — and
the UptimeRobot monitor REN-2 adds — is reachability only and stays
green while paperless is dead behind a live gate. This adds the app
health half of the pair, from the in-cluster vantage.

The target is /accounts/login/ because paperless-ngx has no
unauthenticated health endpoint. It is the app's own established
"the webserver answers" signal — the path its startup, liveness and
readiness probes already use — and it renders a Django view rather
than a static file. It does not cover the consumer/celery workers,
and the comment says so.

No CiliumNetworkPolicy change, and no edit to the paperless namespace.
The gatus comment above asserts an in-cluster probe needs one first;
that was written for gatus, which runs in `apps`. paperless-ns.yaml:24-26
already admits the whole monitoring namespace, where blackbox-exporter
runs, and Cilium unions the allow rules of every CNP selecting an
endpoint — the same mechanism apps/paperless/network-policy.yaml:8-12
relies on.

No PrometheusRule change: the target is covered by the existing
BlackboxProbeFailing catch-all and is correctly absent from its
exclusion list, which per prometheusrule-blackbox.yaml:18-33 holds only
instances that have a dedicated rule elsewhere.

Refs REN-21.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 18:51:33 +00:00
argus-bot (Paperclip service account)andClaude Opus 5 16238d39ce Merge PR #976: in-cluster app-health probes for navidrome and lidarr (REN-21)
Reviewed by argus-bot (four-eyes: authored by atlas-bot, merged by argus-bot).
One file, kubernetes/apps/monitoring-rules/probes-blackbox.yaml, additive only.
blackbox-http 17 -> 19 targets; other two Probe documents unchanged.
Revert: delete the added target lines and comment block (tested clean).
Do NOT use git revert -m 1 on this merge once #977 has landed -- the natural
conflict resolution silently removes the paperless probe too. See REN-22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:50:22 +00:00
svc-atlasandPaperclip 2219c928e0 feat(monitoring): in-cluster app-health probes for navidrome and lidarr
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
navidrome.renner.uno and lidarr.renner.uno are served entirely through
Authentik outposts. An external probe of either name therefore measures
DNS, TLS, Traefik and the gate outpost — and stays green while the app
behind a live gate is dead. This adds the second signal the
monitoring-vantage-point-rule asks for: an in-cluster probe of the
ClusterIP, which goes red on the app dying regardless of the gate.

Targets are /ping on both, the same path the pods' own kubelet
liveness/readiness probes already use. That it is ungated is not an
assumption: lidarr's reconcile script curls /ping in a loop before it
has read the API key out of config.xml (lidarr.yaml:29).

No CiliumNetworkPolicy change, and no edit to the navidrome namespace at
all. The wiki gotcha
cnp-cross-namespace-allow-apps-gatus-to-monitored-services is about
`apps`, where gatus runs; the influxdb allow-list it describes already
contained `monitoring`. navidrome-ns.yaml:25-27 likewise already admits
the whole monitoring namespace, which is where blackbox-exporter runs.

No PrometheusRule change either: both targets are covered by the
existing BlackboxProbeFailing catch-all and are correctly absent from
its exclusion list, which per prometheusrule-blackbox.yaml:18-33 holds
only instances that have a dedicated rule elsewhere.

Refs REN-21.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 18:25:58 +00:00
k.renner d36ffc80c2 Merge pull request 'feat(wiki): API key and network path for Paperclip agents' (#974) from feat/paperclip-wiki-key into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#974
2026-09-25 17:42:31 +00:00
k.renner 8b6e93d412 Merge pull request 'feat(lidarr): missing-album view over the Navidrome library' (#973) from feat/lidarr into master
Reviewed-on: k.renner/homelab#973
2026-09-25 17:42:06 +00:00
claude-botandClaude Opus 5.5 157b102519 feat(wiki): API key and network path for Paperclip agents
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Paperclip agents get their own wiki machine identity: OpenBao
apps/ai/wiki-api-keys field `paperclip` (already written, version 5),
mapped to Auth__ApiKeys__paperclip. Reloader rolls the wiki on the new
key.

Agents reach the service in-cluster at wiki.ai.svc.cluster.local:8080,
like the CI agents, so only the app-level API key applies (no Traefik
BasicAuth user needed). The wiki CNP admits the paperclip pod on 8080
and the paperclip egress CNP allows exactly that pod and port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:35:29 +02:00
k.renner cf362548f1 Merge pull request 'feat(wiki): API key "paperclip" for the Paperclip agents' (#972) from feat/wiki-api-key-paperclip into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#972
2026-09-25 17:35:27 +00:00
claude-botandClaude Opus 5.5 378e433086 feat(lidarr): missing-album view over the Navidrome library
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Lidarr in the navidrome namespace as a "what is missing" UI: artist
discographies (Wanted -> Missing) and Spotify followed artists / saved
albums / playlists as import lists.

- Mounts navidrome-music-bulk read-write (Lidarr has no read-only root
  folder mode); config on ceph-block.
- A reconcile sidecar (same image, curl+jq) reads the API key from
  /config/config.xml and on every start sets renameTracks=false,
  writeAudioTags=no, no recycle bin, no empty-folder deletes, no
  permission changes -- then adds /music as root folder only if absent,
  so the automatic library import never runs with file-mutating
  settings.
- No download client or indexer.
- AuthenticationMethod=External behind a dedicated lidarr-gate outpost
  restricted to a new "Music Librarians" group (k.renner).
- Image automation on the linuxserver build number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:33:54 +02:00
claude-botandClaude Fable 5.1 f732528551 feat(wiki): API key "paperclip" for the Paperclip agents
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Second named key in apps/ai/wiki-api-keys (OpenBao version 4), mapped to
Auth__ApiKeys__paperclip; writes through the API are attributed to it.
Reloader rolls the pod when the Secret changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 19:33:38 +02:00
svc-argus (Paperclip service account) 890eab7dc3 Merge pull request 'test(access): remove the REN-3 branch protection probe file' (#970) from test/ren-3-access-cleanup into master
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-09-25 13:17:37 +00:00
svc-atlasandPaperclip 5343b65562 test(access): remove the REN-3 branch protection probe file
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Leaves master's tree byte-identical to before the test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 13:17:36 +00:00
svc-argus (Paperclip service account) f56391d88f Merge pull request 'test(access): REN-3 branch protection probe' (#969) from test/ren-3-access-check into master 2026-09-25 13:17:22 +00:00
svc-atlasandPaperclip d32bf7a4ab test(access): REN-3 branch protection probe
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-25 13:17:00 +00:00
k.renner e82303c314 Merge pull request 'feat(paperclip): disable sign-up; document the Claude connection' (#967) from feat/paperclip-signup-off into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#967
2026-09-25 11:57:08 +00:00
claude-botandClaude Opus 5.5 56e30bf065 fix(paperclip): agents use the Service name for the API
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The cluster-internal name keeps working when runs move to sandbox pods
(option C), where loopback would not reach the server. The name is
added to PAPERCLIP_ALLOWED_HOSTNAMES because the private-hostname guard
otherwise returns 403 for it. Same-namespace traffic is already allowed
by the baseline CNP in both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:52:48 +02:00
claude-botandClaude Opus 5.5 161d0561e7 fix(paperclip): agents call the API on loopback, not through the gate
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Upstream derives the agents' PAPERCLIP_API_URL from the public URL, so
agent API calls went out through Traefik to the paperclip-gate outpost.
With intercept_header_auth the outpost treats the agent's Authorization
header as an Authentik credential and returns 401; agents could not
create tasks, post cards or update status.

Agents run in the server pod (option B), so PAPERCLIP_API_URL is set to
http://localhost:3100 (upstream keeps an explicit value and passes it to
every run). Loopback passes the private-hostname guard and agent JWTs
still authenticate each call; the gate stays fully enforced for browser
traffic instead of carving out /api/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:51:43 +02:00
claude-botandClaude Opus 5.5 30931d3f50 feat(paperclip): disable sign-up; document the Claude connection
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The first admin is claimed, so PAPERCLIP_AUTH_DISABLE_SIGN_UP flips to
true.

Spec: Claude auth is a Paperclip-managed subscription connection created
once via the local sign-in. The server-env CLAUDE_CODE_OAUTH_TOKEN was
never used (stripAiAuthBindings blanks it for managed runs; upstream
accepts no pasted setup-token), so it is dropped from the spec. The
login must run as the node user: exec sessions start as root and
root-owned 0600 credentials are unreadable to the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:48:29 +02:00
security-bot bd14b3004c chore(image-automation): bump ghcr.io/paperclipai/paperclip:nightly@sha256:9222efbf11863160669e13d5559e6a1be881886dd1758650b74c3993dc3ef2a6
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-25 11:25:18 +00:00
k.renner 2c25acbfdd Merge pull request 'feat(paperclip): deploy Paperclip (server-local runs, trial)' (#966) from feat/paperclip into master
Reviewed-on: k.renner/homelab#966
2026-09-25 11:24:23 +00:00
claude-botandClaude Opus 5.5 50cf3e977a docs(paperclip): align spec with implementation and review fixes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:22:43 +02:00
claude-botandClaude Opus 5.5 bb3a923c43 fix(paperclip): probes pass the hostname guard; tighten egress and SA token
- Paperclip's private-hostname guard (authenticated+private) returns 403
  for any Host but loopback and the public URL. Kubelet probes send the
  pod IP, so the pod could never become Ready. Probes now send
  Host: localhost.
- Liveness is a TCP check: /api/health runs SELECT 1, so a Postgres
  outage would have restarted the server and killed in-flight runs.
- Baseline CNP allowed world on every port; Cilium unions allows across
  CNPs, so the app-level :443 restriction was not enforced. world is
  now :443-only in the baseline as well.
- automountServiceAccountToken: false. Every agent shares the pod
  filesystem; bots bring their own cluster credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:22:17 +02:00
claude-botandClaude Opus 5.5 78cac12367 feat(paperclip): Flux Kustomization
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:08:47 +02:00
claude-botandClaude Opus 5.5 5a22ea8df3 feat(paperclip): server Deployment, PVC, gated Ingress and CNPs
- Sign-up stays enabled: upstream's first-admin claim requires an
  existing signed-in Better Auth account (routes/access.ts
  /bootstrap/claim), so disabling it would lock out the claim. A
  follow-up flips PAPERCLIP_AUTH_DISABLE_SIGN_UP after the claim.
- Telemetry is off via PAPERCLIP_TELEMETRY_DISABLED=1, which
  resolveTelemetryConfig honours; config.json can't carry it alone
  because the schema requires a complete file.
- Container starts as root: docker-entrypoint.sh remaps the node user
  and chowns /paperclip before dropping privileges with gosu.
- Image pinned to the amd64 manifest digest of nightly; Flux digest
  reflection takes over from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:08:32 +02:00
claude-botandClaude Opus 5.5 cb81157216 feat(authentik): paperclip-gate outpost and Paperclip Users group
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:07:14 +02:00
claude-botandClaude Opus 5.5 d3257c56ac feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:06:22 +02:00
claude-botandClaude Opus 5.5 2d56534581 feat(paperclip): Flux image automation tracks the nightly digest
digestReflectionPolicy: Always requires spec.interval on the ImagePolicy
(admission rejects it otherwise); set to 1h to match the ImageRepository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:05:43 +02:00
claude-botandClaude Opus 5.5 6fa4c340b8 feat(paperclip): namespace, baseline CNP and OpenBao allowlist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:29 +02:00
claude-botandClaude Opus 5.5 c3c8662ca7 docs(paperclip): implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:59:16 +02:00
claude-botandClaude Opus 5.5 c2a2481313 docs(paperclip): design spec for running Paperclip in the cluster
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:44:47 +02:00
k.renner 48bbe511b8 Merge pull request 'feat(netbird): retire the reverse proxy on the management host' (#965) from feat/netbird-retire-vps-proxy into master
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#965
2026-09-25 09:26:20 +00:00
k.renner 8d98acb3a4 Merge pull request 'feat(navidrome): self-hosted music streaming on the bulk pool' (#964) from feat/navidrome into master
Reviewed-on: k.renner/homelab#964
2026-09-25 09:19:19 +00:00
claude-botandClaude Opus 5.5 adbfd3bfc6 feat(netbird): retire the reverse proxy on the management host
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The public edge is proxy-1/proxy-2; proxy.renner.uno has not pointed at
netbird.renner.uno since the netcup fleet took over. The proxy that kept
running on the management host has served nothing since the controller
moved every service to TLS passthrough (2026-09-07): passthrough routes
live on the proxy's own :443 listener, while the host's Traefik forwards
HostSNI(*) to its :8443 main listener, which answers "unknown domain".

Its watchdog therefore failed every probe round and ran `docker restart`
every ~3 minutes since 2026-09-07 12:53 (last healthy round). Each
restart registered a fresh proxy peer and pushed a network-map update to
every peer in the mesh — ~10 stale proxy-* identities per ingress peer
and a map update every 1-2 minutes.

- netbird-server: drop the proxy service, its Traefik TCP passthrough,
  proxy.env and the proxy vars; `compose up --remove-orphans` removes
  the container, and one-off tasks stop/remove both watchdogs and delete
  the proxy volumes, config and state.
- install-netbird-server.yml no longer applies netbird-proxy-watchdog;
  its pipeline no longer triggers on that role.
- Comments that described the management host as a cluster member now
  describe the proxy hosts only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:17:46 +02:00
claude-botandClaude Opus 5.5 222d0263a4 feat(navidrome): self-hosted music streaming on the bulk pool
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Navidrome (Subsonic API) as the Spotify replacement for phone apps.

- Library PVC on ceph-filesystem-bulk (RWX, 300Gi, mounted read-only);
  SQLite data on ceph-block (RWO).
- Web UI behind a dedicated navidrome-gate Authentik proxy outpost;
  Navidrome trusts X-Authentik-Username (ExtAuth) and auto-creates the
  user on first SSO login (first user = admin).
- /rest and /share routed straight to Navidrome with the identity header
  stripped, so Subsonic clients use their Navidrome password.
- Namespace baseline CNP, gate egress CNP, Flux image automation,
  Velero daily schedule (09:15), bootstrap wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:12:08 +02:00
k.renner c765a42503 Merge pull request 'feat(immich): serve media from the EC bulk pool, thumbs replicated' (#962) from feat/immich-bulk-cutover into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#962
2026-09-25 08:03:59 +00:00
claude-botandClaude Opus 5.5 4cc1b643d3 feat(immich): serve media from the EC bulk pool, thumbs replicated
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
immich-server mounts /data from immich-library-bulk (EC 2+1) and
/data/thumbs from immich-thumbs (3x replicated). Data was copied with
rsync -aH from immich-library and verified per folder: file counts and
byte totals match for library, upload, encoded-video, profile, backups
and thumbs.

immich-library stays in pvcs.yaml until a Velero backup of the new
volumes has completed; its removal is a separate change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:00:42 +02:00
security-bot 09b40153e6 chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1790322994
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-25 07:58:58 +00:00
k.renner fc5712d651 Merge pull request 'feat(immich): provision bulk media and thumbs PVCs' (#961) from feat/immich-bulk-pvcs into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#961
2026-09-25 07:44:26 +00:00
k.renner 87072d7781 Merge pull request 'feat(wiki): Flux image automation tracks build.<ts> tags' (#960) from feat/wiki-image-automation into master
Reviewed-on: k.renner/homelab#960
2026-09-25 07:42:05 +00:00
claude-botandClaude Opus 5.5 2a502e540a feat(immich): provision bulk media and thumbs PVCs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adds immich-library-bulk (ceph-filesystem-bulk, EC 2+1, 300Gi) for
the media root and immich-thumbs (ceph-filesystem, 3x, 30Gi) for
thumbs. Thumbnail reads stay on the replicated pool, where CSI read
affinity serves them from the local OSD; an EC read always
reassembles chunks from two OSDs.

Both PVCs start empty and nothing mounts them yet. The data copy and
the server cutover follow separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:40:33 +02:00
claude-botandClaude Fable 5.1 e0db01dbf6 feat(wiki): Flux image automation tracks build.<ts> tags
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ImageRepository and ImagePolicy for gitea.renner.uno/konairius/wiki, same
shape as shelly-manager; the deployment carries the setter marker, so the
existing ImageUpdateAutomation bumps the tag on every main build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:36:04 +02:00
k.renner 6a7ffd9b65 Merge pull request 'chore(wiki): image build.1790319474 (statement to fact)' (#959) from chore/wiki-image-fact-drafter into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#959
2026-09-25 07:33:57 +00:00
claude-botandClaude Fable 5.1 649c4b3f5b chore(wiki): image build.1790319474 (statement to fact)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Wiki main after PR #12: /facts is one text box; the drafter turns the
statement into a raw source under the signed-in user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:29:40 +02:00
k.renner 7caa316082 Merge pull request 'chore(wiki): image build.1790317650 (Facts page)' (#958) from chore/wiki-image-facts-page into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#958
2026-09-25 06:39:37 +00:00
claude-botandClaude Fable 5.1 213b984550 chore(wiki): image build.1790317650 (Facts page)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Wiki main after PR #11: /facts deposits a raw source under the signed-in
user through the same use case as the API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 08:29:39 +02:00
k.renner 68e2444a71 Merge pull request 'chore(audiobookshelf): drop the replicated books PVC' (#957) from chore/abs-drop-old-books-pvc into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#957
2026-09-25 06:06:37 +00:00
k.renner af8983c5a7 Merge pull request 'chore(wiki): image build.1790316170 (one judge request per ask)' (#956) from chore/wiki-image-judge-one-request into master
Reviewed-on: k.renner/homelab#956
2026-09-25 06:06:19 +00:00
claude-botandClaude Opus 5.5 2dde64d1b6 chore(audiobookshelf): drop the replicated books PVC
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Books live on audiobookshelf-books-bulk since #955; nothing mounts
audiobookshelf-books any more. The PVC has prune disabled, so removing
it from git does not delete it; the PVC, its Retain PV and the CephFS
subvolume are deleted by hand after merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:05:14 +02:00
claude-botandClaude Fable 5.1 b7502c28bf chore(wiki): image build.1790316170 (one judge request per ask)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Wiki main after PR #10: every typed question rides in the single request
that carries the state, halving TypeSafe input tokens per ask.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 08:05:12 +02:00
k.renner fabd86e111 Merge pull request 'feat(audiobookshelf): move books to the EC bulk pool' (#955) from feat/abs-books-bulk into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#955
2026-09-25 05:49:44 +00:00
claude-botandClaude Opus 5.5 77d1e050f0 feat(audiobookshelf): move books to the EC bulk pool
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Points the audiobookshelf Deployment and the libation CronJob at
audiobookshelf-books-bulk (ceph-filesystem-bulk, EC 2+1). The data was
copied with rsync -aH from the old PVC and verified: 574 files,
87315610958 bytes on both sides.

audiobookshelf-books stays in pvcs.yaml (prune disabled) until the
new volume is confirmed in use; its removal is a separate change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:48:11 +02:00
k.renner 407c366474 Merge pull request 'feat(libation): strip the Audible intro/outro from downloads' (#954) from feat/libation-strip-branding into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#954
2026-09-25 05:41:56 +00:00
k.renner 26e3dad557 Merge pull request 'feat(rook-ceph): add EC 2+1 bulk data pool to cephfs' (#953) from feat/cephfs-ec-bulk into master
Reviewed-on: k.renner/homelab#953
2026-09-25 05:38:39 +00:00
claude-botandClaude Opus 5.5 5bca64898b feat(libation): strip the Audible intro/outro from downloads
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Enables StripAudibleBrandAudio (Libation trims the branding using Audible's
own chapter offsets, lossless) and OverwriteExisting, so a re-liberated book
replaces its file instead of landing next to it as 'Title (1)'.

Settings.json on the PVC is seeded once and the entrypoint copies it into
ephemeral /config-internal each run, so the flags are applied there with jq
(already in the image; the entrypoint uses it for Books/InProgress).

Only affects books decrypted from now on; existing files need a re-liberate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:38:35 +02:00
claude-botandClaude Opus 5.5 52b294ed22 feat(rook-ceph): add EC 2+1 bulk data pool to cephfs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Adds a secondary erasure-coded (2+1, failureDomain host) data pool
'bulk' to the cephfs filesystem and a ceph-filesystem-bulk StorageClass
targeting it. data0 stays the default data pool, as Rook requires for
EC CephFS pools. Bulk media at 1.5x instead of 3x raw overhead.

Behaviour matches the existing migration.rgw.buckets.data EC pool:
Rook sets ec_overwrites, min_size 2 (=k), so IO continues through a
single-node reboot without redundancy for that window.

Also provisions an empty audiobookshelf-books-bulk PVC on the new
class. Deployment and libation CronJob still mount audiobookshelf-books;
the data copy and cutover follow separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:29:04 +02:00
k.renner c3ce97236d Merge pull request 'feat(mosquitto): add the open3e-int broker user for the internal-CAN gateway' (#952) from feat/open3e-int-broker-user into master
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#952
2026-09-24 18:22:02 +00:00
claude-botandClaude Fable 5.1 74b22853a9 feat(mosquitto): add the open3e-int broker user for the internal-CAN gateway
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The open3e-esp32 gateway on the Vitocal 250-A internal CAN (ECU 0x6e0,
room setpoints) publishes and listens under open3e/int/#. A separate
broker user keeps its command topic out of edge-1's open3e/cmnd listener
and gives the credential its own lifecycle.

Follows the health credential pattern: OpenBao-backed via ExternalSecret,
Class-A rotator CronJob (quarterly, months 3/6/9/12), per-secret policy
and kubernetes auth role, Reloader annotation on the broker.

Out-of-band steps done on 2026-09-24 with an admin token: policy written,
auth role created, path seeded with a 32-char alphanumeric password.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNSJsCKwMip9y9YEZhDqxw
2026-09-24 20:11:01 +02:00
k.renner 5c780290a8 Merge pull request 'feat(wiki): roll the pod when one of its Secrets changes' (#951) from feat/wiki-reloader into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#951
2026-09-24 15:12:20 +00:00
claude-botandClaude Fable 5.1 251f8cd87e feat(wiki): roll the pod when one of its Secrets changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
All credentials reach the service via envFrom, which is read once at pod
start. Reloader watches the four Secrets and restarts the Deployment on a
change, so a corrected or rotated key takes effect without a manual restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:11:45 +02:00
k.renner 4ee56fb94a Merge pull request 'chore(wiki): image build.1790261926 (facts, questions, lab)' (#950) from chore/wiki-image-phases-4-6 into master
Reviewed-on: k.renner/homelab#950
2026-09-24 15:01:52 +00:00
claude-botandClaude Fable 5.1 4bc9ad89fd chore(wiki): image build.1790261926 (facts, questions, lab)
ci/woodpecker/pr/ci-manifests-test Pipeline is pending
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/pr-review Pipeline is pending
Wiki main at 8fa9f20: POST /api/v1/facts, POST /api/v1/questions with the
Anthropic answer model and the Answers page, POST /api/v1/lab/eval and the
Lab page. The pod restart also picks up the Anthropic key from wiki-anthropic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:01:13 +02:00
k.renner e6ba50d676 Merge pull request 'feat(wiki): wire the Anthropic answer-model key from apps/ai/wiki-anthropic' (#949) from feat/wiki-anthropic-key into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#949
2026-09-24 14:50:55 +00:00
k.renner 1431c74fda Merge pull request 'chore(wiki): the service's Gitea token comes from apps/ai/wiki-service (write:repository)' (#948) from feat/wiki-service-write-token into master
Reviewed-on: k.renner/homelab#948
2026-09-24 14:50:45 +00:00
claude-botandClaude Fable 5.1 6784bb5600 feat(wiki): wire the Anthropic answer-model key from apps/ai/wiki-anthropic
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Wiki PR #7 adds the questions endpoint backed by the Anthropic Messages API.
The key arrives as Wiki__AnswerModels__Models__claude-sonnet__ApiKey via an
ExternalSecret; the secretRef is optional so the pod keeps starting while the
OpenBao key does not exist yet. Namespace egress already admits world, so no
CNP change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 16:45:12 +02:00
claude-botandClaude Fable 5.1 64fdb568b4 chore(wiki): the service's Gitea token comes from apps/ai/wiki-service (write:repository)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Facts deposited through POST /api/v1/facts are committed as raw/ files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 16:28:55 +02:00
k.renner db957a3441 Merge pull request 'chore(wiki-index): retire the Python service' (#947) from chore/retire-python-wiki-index into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#947
2026-09-24 14:19:54 +00:00
claude-botandClaude Fable 5.1 419cbed395 chore(wiki-index): retire the Python service
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The .NET wiki service has parity (same recall on the labelled set), serves
wiki-index.renner.uno and the notes webhook, and the workstation scripts
use it. Removes the app, its Flux Kustomization, image source and build
step, its CNP entries and the OpenBao policy file. The OpenBao role and
the old Gitea webhook are removed live after the merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 16:13:55 +02:00
k.renner 27a54e6ede Merge pull request 'feat(wiki): wiki-index.renner.uno becomes the wiki service's machine door' (#946) from feat/wiki-machine-door into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#946
2026-09-24 14:08:14 +00:00
claude-botandClaude Fable 5.1 38156c12a7 feat(wiki): wiki-index.renner.uno becomes the wiki service's machine door
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
BasicAuth as before, X-Authentik-Username stripped on that path, Traefik
admitted by the wiki CNP again. The Python service keeps running without
an ingress until the scripts are verified against the new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:59:58 +02:00
k.renner 64f29ce419 Merge pull request 'chore(wiki): build.1790257574 (ask API, TypeSafe judge)' (#945) from chore/wiki-phase3-image into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#945
2026-09-24 13:56:05 +00:00
claude-botandClaude Fable 5.1 0775dac615 chore(wiki): build.1790257574 (ask API, TypeSafe judge)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:48:47 +02:00
k.renner 3f20dddc22 Merge pull request 'chore(wiki): build.1790257176 (read API and pages); TypeSafe key as ExternalSecret' (#944) from chore/wiki-phase2-image into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#944
2026-09-24 13:45:05 +00:00
claude-botandClaude Fable 5.1 edc07f2543 chore(wiki): build.1790257176 (read API and pages); TypeSafe key as ExternalSecret
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:42:12 +02:00
k.renner c83c1dc7a5 Merge pull request 'chore(wiki): build.1790250017; proposal without the fact-sink port' (#943) from chore/wiki-build-1790250017 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#943
2026-09-24 13:40:13 +00:00
claude-botandClaude Fable 5.1 fc32e99438 chore(wiki): build.1790250017; proposal: the store creates documents, no fact-sink port
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 13:42:53 +02:00
k.renner 82ffca3aaa Merge pull request 'feat(wiki): Authentik wiki-gate in front of wiki.renner.uno' (#942) from feat/wiki-gate into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#942
2026-09-24 11:18:44 +00:00
k.renner 21c4c8c96b Merge pull request 'netpol(gitea): let the notes push webhook reach the wiki service' (#941) from fix/gitea-egress-wiki into master
Reviewed-on: k.renner/homelab#941
2026-09-24 11:18:32 +00:00
claude-botandClaude Fable 5.1 ae5de9bc84 feat(wiki): Authentik wiki-gate in front of wiki.renner.uno
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Proxy provider, application and outpost blueprint; outpost egress CNP;
ExternalName mirror; the ingress backends the outpost and the BasicAuth
middleware is gone. The ai CNP admits the outpost and Gitea only, so the
X-Authentik-Username header the service trusts from pod addresses cannot
arrive from the LAN.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:48:22 +02:00
claude-botandClaude Fable 5.1 3b0c42473a netpol(gitea): let the notes push webhook reach the wiki service
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Gitea's egress to ai was scoped to the wiki-index pod; deliveries to
wiki.ai.svc timed out (hook 17, 2026-09-24).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:46:03 +02:00
k.renner ad152d2d16 Merge pull request 'feat(wiki): deploy the .NET wiki service next to the Python wiki-index' (#940) from feat/wiki-service-manifests into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#940
2026-09-24 10:29:43 +00:00
claude-botandClaude Fable 5.1 0cfc4dc486 feat(wiki): deploy the .NET wiki service next to the Python wiki-index
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
PVC, Deployment (build.1790245544), Service, ExternalSecrets for the
shared Gitea token/webhook secret and the API keys, LAN-only ingress
wiki.renner.uno behind BasicAuth, Flux Kustomization, CNP admitting
Traefik and Gitea. No OpenBao access from the pod.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:28:17 +02:00
k.renner 50c62da2f4 Merge pull request 'docs(wiki): proposal for a local wiki web interface and agent API' (#939) from docs/proposal-wiki-web-api into master
Reviewed-on: k.renner/homelab#939
2026-09-24 10:26:17 +00:00
claude-botandClaude Fable 5.1 abb4096c67 docs(wiki): in-process questions endpoint with an answer-model port; Anthropic adapter first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 11:11:10 +02:00
claude-botandClaude Fable 5.1 12e1a6e6f0 docs(wiki): the service never runs or triggers agents; drop the agent-runner port and Woodpecker
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 11:04:14 +02:00
claude-botandClaude Fable 5.1 b3dc3f6b4c docs(wiki): health endpoints for the cluster deployment; secrets via ExternalSecret, no OpenBao access from the pod
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 10:58:21 +02:00
claude-botandClaude Fable 5.1 b4de51b412 docs(wiki): rewrite as a modular .NET solution with MudBlazor, ports and adapters
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 10:54:04 +02:00
claude-botandClaude Fable 5.1 2bb2704b88 docs(wiki): TypeSafe is a component for agents and code; answers in words come from Claude via wiki-synth
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 10:45:07 +02:00
claude-botandClaude Fable 5.1 ee5f3acbaa docs(wiki): /ask returns typed judgements, not prose; summarize is a separate local step
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 10:43:43 +02:00
claude-botandClaude Fable 5.1 80a0ee5b19 docs(wiki): proposal for a local wiki web interface and agent API
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 10:39:56 +02:00
security-bot 476c48c0af chore(image-automation): bump twinproduction/gatus:v5.37.0
ci/woodpecker/cron/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-24 00:39:00 +00:00
security-bot 8200ce1898 chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.75.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-23 20:38:59 +00:00
k.renner 17bf12cc4f Merge pull request 'fix(alertmanager): route InfoInhibitor to null and add the info inhibit rules' (#936) from fix/alertmanager-info-inhibitor into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#936
2026-09-23 19:24:22 +00:00
claude-botandClaude Opus 5.5 1fc8073218 fix(alertmanager): route InfoInhibitor to null and add the info inhibit rules
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Our hand-written Alertmanager config replaced the chart's default and
dropped two pieces kube-prometheus relies on:

- a route sending InfoInhibitor to the null receiver
- the inhibit rules that let InfoInhibitor (and warning/critical)
  suppress severity=info alerts

So both the helper alert and the info alerts it exists to silence were
emailed, daily. InfoInhibitor fired over the last 7 days in crowdsec,
kube-system, woodpecker-ci and without a namespace, driven by
CPUThrottlingHigh (crowdsec-agent, whereabouts-reconciler),
KubeletTooManyPods and KubeQuota* in woodpecker-ci.

Route and the three inhibit rules are copied from kube-prometheus
manifests/alertmanager-secret.yaml. Info alerts now reach email only
while a warning or critical alert fires in the same namespace, which is
the upstream design. amtool check-config passes; amtool config routes
test sends InfoInhibitor to null.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:23:29 +02:00
k.renner 161c04daff Merge pull request 'feat(rook-ceph): allow only aes256k CephX keys (#527)' (#935) from feat/cephx-allowed-ciphers-aes256k into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#935
2026-09-23 19:15:33 +00:00
187 changed files with 13015 additions and 1106 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ steps:
bash scripts/ci/make-diag-kubeconfig.sh
- name: alert-triage-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+66
View File
@@ -0,0 +1,66 @@
# Paperclip agent runtime images (REN-284 / REN-286): the stock
# agent-runtime-claude plus a tool set, one image per sandbox environment.
# Built with kaniko like build-ci-images.yml and pushed with the same
# ci_images_token (package read+write only, see the header there).
#
# Own workflow, not a step in build-ci-images.yml. A change to that file
# rebuilds all of its images at once and fills the 24-CPU woodpecker-ci-quota;
# on the REN-286 merge (pipeline 11306) this step then got no pod
# ("exceeded quota") and the go image was never built. Here it runs only when
# its own sources change, and it can be re-run alone with a deployment to
# target `build-agent-runtime`, without rebuilding the other images.
#
# The name looks odd on purpose. The kubernetes sandbox plugin keeps the image
# name and tag `agent-runtime-claude:v1` and swaps only the registry prefix
# (`imageRegistry`), replacing host + org. So each variant needs its own
# prefix with the same name under it:
# imageRegistry gitea.renner.uno/konairius/agent-runtime-go
# -> gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1
# `v1` is what the plugin pulls and moves on every build; the date tag is the
# rollback point. konairius/ because the ci_images_token can push there and
# it is the target of the gitea_admin/* migration (#330).
when:
- event: push
branch: master
path:
- "ci/images/agent-runtime-*/**"
- ".woodpecker/build-agent-runtime.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-agent-runtime"'
steps:
- name: build-agent-runtime
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images
# Context is ci/images (not the variant dir) so both Containerfiles can
# COPY the shared agent-runtime-common/ files.
# One pod, both images one after the other (kaniko --cleanup between
# builds), at 1 CPU so it still fits if it overlaps another pipeline.
backend_options:
kubernetes:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 4Gi
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
for VARIANT in go ops; do
/kaniko/executor \
--cleanup \
--context . \
--dockerfile "agent-runtime-$VARIANT/Containerfile" \
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:$TAG" \
--destination "gitea.renner.uno/konairius/agent-runtime-$VARIANT/agent-runtime-claude:v1" \
|| exit 1
done
+6 -31
View File
@@ -26,8 +26,14 @@
# 38-reference change tracked separately in #330 rather than bundled here.
# Note this pipeline runs no LLM agent, so the exfil path that motivated the
# other cutovers does not apply to it.
#
# Push builds run on master only. Without the branch filter a feature branch
# that touches this file started all steps at once: it pushed unreviewed
# images and used the whole woodpecker-ci-quota, so the PR checks could not
# get a pod (REN-286).
when:
- event: push
branch: master
path:
- "ci/images/**"
- "secret-rotator/**"
@@ -377,37 +383,6 @@ steps:
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:latest
# wiki-index: embedding index over the Notes repo's wiki/, fed from Gitea and
# queried by ~/Notes/bin/wiki_rank.py. Runtime artifact like netbird-service-sync.
- name: build-wiki-index
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/wiki-index
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/wiki-index/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
# Also a per-commit tag: several builds a day (branch pushes and the
# merge) all rewrite the dated tag, and the deployment pin raced them
# on 2026-09-20. The commit tag is immutable and needs no digest lookup.
- SHORT=$(printf '%s' "$CI_COMMIT_SHA" | cut -c1-8)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/wiki-index:$TAG \
--destination gitea.renner.uno/gitea_admin/wiki-index:$TAG-$SHORT \
--destination gitea.renner.uno/gitea_admin/wiki-index:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
+69
View File
@@ -0,0 +1,69 @@
# Paperclip server image (REN-401): upstream paperclipai/paperclip at a pinned
# master commit plus the patches in ci/images/paperclip/. Needed while the k8s
# sandbox fix (upstream PR #13733) is not merged; see the README there for how
# to go back to upstream nightly.
#
# Own workflow for the reason given in build-agent-runtime.yml: a step in
# build-ci-images.yml runs on every change to that file and competes for the
# woodpecker-ci-quota. Re-run alone with a deployment to target
# `build-paperclip`.
#
# Tag `build.<unix-ts>` is what the Flux ImagePolicy `paperclip` sorts on
# (image-automation/paperclip.yaml); image automation then rolls it out.
# konairius/ and ci_images_token as for the agent-runtime images.
when:
- event: push
branch: master
path:
- "ci/images/paperclip/**"
- ".woodpecker/build-paperclip.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-paperclip"'
steps:
- name: prepare-source
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- sh ci/images/paperclip/prepare.sh build/paperclip-src
- name: build-paperclip
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: build/paperclip-src
depends_on: [prepare-source]
# Rust runner + UI + server build in one pod. The server build alone sets
# a 4 GiB Node heap, so the memory limit is the LimitRange max (8Gi).
backend_options:
kubernetes:
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
cpu: "4"
memory: 8Gi
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- . ../../ci/images/paperclip/upstream.env
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=build.$(date +%s)
# Same target and build args as upstream's docker.yml for nightly.
# --compressed-caching=false: pipeline 11717 was killed at the 8Gi limit
# (exit 137) in the server build; kaniko otherwise holds compressed
# layer tarballs in memory next to the 4 GiB Node heap.
- |
/kaniko/executor \
--context . \
--dockerfile Dockerfile.kaniko \
--target production \
--skip-unused-stages \
--snapshot-mode=redo \
--compressed-caching=false \
--build-arg PAPERCLIP_BUILD_COMMIT=$PAPERCLIP_COMMIT \
--build-arg PAPERCLIP_BUILD_VERSION=$PAPERCLIP_VERSION \
--build-arg CLI_TOOLS_CACHE_EPOCH=$(date -u +%G-W%V) \
--destination gitea.renner.uno/konairius/paperclip:$TAG
+98 -15
View File
@@ -17,30 +17,44 @@
# here is a static check that either passes or fails on the checkout alone, so it
# can gate PRs without becoming a bottleneck or a flake source.
when:
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is one
# step on one already-cached image, so running it everywhere is free; this one
# pulls three images and builds every kustomization in the tree, which is real
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is two
# steps on already-cached images, so running it everywhere is free; this one
# pulls four images and builds every kustomization in the tree, which is real
# cost to put on a docs-only PR. The filter covers everything the steps actually read:
# kubernetes/** for the manifests and scripts, .woodpecker/** for the lint (and
# so an edit to this file re-runs its own checks).
# kubernetes/** for the manifests, scripts and promtool suites, .woodpecker/**
# for the lint (and so an edit to this file re-runs its own checks), and the
# promtool driver script by name.
#
# That last entry is NOT covered by kubernetes/**: promtool-rules.sh lives
# under scripts/, so without it a PR that only edits the script would never run
# the check it drives. It is listed as one file rather than scripts/ci/**
# because the rest of that directory is CI-agent helpers with nothing to do
# with this pipeline, and pulling four images for them is the cost this filter
# exists to avoid. Their own tests run in ci-scripts-test.yml on every PR.
- event: pull_request
path:
- "kubernetes/**"
- ".woodpecker/**"
- "scripts/ci/promtool-rules.sh"
- "scripts/ci/yaml-citations.sh"
# ADDING A THIRD DISCOVERY CHECK: kustomize-build and shellcheck both hand-roll
# the same shape — `find`, a floor guard against empty discovery, then a
# file-fed `while read`. Two copies is deliberate: the two traps being commented
# around (BusyBox find lacking `-printf`, and a piped loop running in a subshell
# where `fail` cannot propagate) are different, and both are worth spelling out
# where they bite. A third instance is the point to stop copying and factor it
# into a shared script under `scripts/ci/` — which is already covered by
# ci-scripts-test.yml, so it would gain unit tests in the process.
# THE THIRD DISCOVERY CHECK WENT TO A SCRIPT. kustomize-build and shellcheck
# both hand-roll the same shape — `find`, a floor guard against empty discovery,
# then a file-fed `while read`. Two copies was deliberate: the two traps being
# commented around (BusyBox find lacking `-printf`, and a piped loop running in
# a subshell where `fail` cannot propagate) are different, and both are worth
# spelling out where they bite. An earlier revision of this comment said a third
# instance would be the point to stop copying and factor it into `scripts/ci/`,
# where it would gain unit tests — and that is what `promtool` below does. Its
# discovery, floors and guards live in scripts/ci/promtool-rules.sh and are
# tested by scripts/ci/test-promtool-rules.sh on every PR via
# ci-scripts-test.yml. A FOURTH inline copy should go the same way; do not add
# one here.
#
# Every step carries `depends_on: []` so all four run in PARALLEL and every
# Every step carries `depends_on: []` so all six run in PARALLEL and every
# failure shows in one run. Woodpecker's default is sequential, which would stop
# at the first failure and make a contributor fix-and-repush to discover the
# next — and these four are independent, cheap, offline checks with no ordering
# next — and these six are independent, cheap, offline checks with no ordering
# between them. Keep it on every step: `depends_on` is per-step, so dropping it
# from one silently re-serialises that step behind the others.
steps:
@@ -258,3 +272,72 @@ steps:
exit 1
fi
echo "ok no digest pins under openbao/rotation/"
# Machine-check the alert rules: parse every PrometheusRule, run the committed
# promtool unit suites, then damage the rules on purpose and require the suites
# to notice. Everything it does lives in scripts/ci/promtool-rules.sh; read
# that file and kubernetes/apps/monitoring-rules/tests/README.md for the why.
#
# The gap it closes: the other four steps prove a PrometheusRule assembles into
# valid YAML and nothing more — not that an expression parses, not that a label
# join matches anything, not that a `for` window does what its comment claims.
# On REN-129 the CNPG surge rule was written `for: 2m`, which fires on six of
# seven measured nights and would have mailed the owner twice a night at 04:16
# and 04:22 local for a backlog that clears itself. Nothing here would have
# caught it, and the arithmetic that did catch it was hand-written from scratch
# twice, in two runs, and thrown away both times because there was nowhere in
# the repo to commit it. REN-152 retired that rule; its `for` margin is now
# guarded by mutations.txt:backlog-for-12m.
#
# Offline, like the rest of this pipeline: no cluster, no registry, no secrets,
# no network. promtool reads files and exits.
#
# IMAGE — prom/prometheus, not a registry-local ci-* image, and pinned to an
# exact upstream version rather than a dated tag. The dated-tag convention
# guarded by no-digest-pins above is about OUR images, whose digests are
# garbage-collected out from under a pin; this is upstream and immutable.
#
# The version must be 3.x, and that is not a preference. The cluster runs
# whatever kube-prometheus-stack >= 72.0.0 ships, which is Prometheus 3, and
# alert `for` evaluation is version-sensitive — a 2.x promtool would be
# checking a different evaluator than production, so the margins the suites
# assert would be claims about the wrong program. promtool-rules.sh asserts the
# major version it was handed and fails on 2.x, so a careless bump here is a
# red step rather than a quietly wrong check. Keep this at the latest stable
# 3.x, per the standing rule that any software uses the latest stable version.
#
# This image is NOT covered by image-automation (same reason as
# woodpecker-lint above: a Woodpecker pipeline file has no apiVersion/kind, so
# an $imagepolicy marker here is silently ignored — fluxcd/flux2#2826). The
# bump is manual.
#
# `prom/prometheus` is busybox-based and ships /bin/sh, so it can run a
# `commands:` block — unlike the plain woodpecker-cli tag, which cannot. Its
# find/grep/awk/sed are BusyBox, which is why promtool-rules.sh avoids
# `find -printf` and `grep -r --include`; it was verified against BusyBox
# 1.35.0 as well as GNU. The image also declares `USER nobody`, so the script
# writes its stripped mirror under mktemp and never into the workspace.
- name: promtool
depends_on: [] # see note above `steps:`
image: prom/prometheus:v3.15.0
commands:
- sh scripts/ci/promtool-rules.sh
# Comments cite other YAML blocks by anchor name, never by line number
# (REN-183; the exact syntax is in CONTRIBUTING.md, not repeated here because
# an example written in this comment would itself be checked). Line numbers drift on any edit
# above them and nothing noticed: three drift fixes were merged by hand
# before this step existed. This fails on a new line-number citation and on
# a citation whose file or anchor marker does not exist.
# Existence only — it never judges whether the anchored block still says
# what the citing comment claims, so ordinary edits cannot turn it red.
#
# It scans every YAML file in the checkout, but this pipeline only runs on
# the paths in `when:` above, so an edit confined to ansible/ or esphome/ is
# checked on the next PR that does touch kubernetes/ or .woodpecker/.
# Self-test: scripts/ci/test-yaml-citations.sh, in ci-scripts-test.yml.
- name: yaml-citations
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- sh scripts/ci/yaml-citations.sh
+34 -4
View File
@@ -8,15 +8,45 @@
# against adversarial PR input. Running it here fails the offending PR's checks
# before it can merge.
#
# scripts/ci/test-promtool-rules.sh is the same idea for the alert-rule gate.
# What it tests is not whether promtool evaluates PromQL — ci-manifests-test.yml
# does that with the real binary against the real rules — but whether
# promtool-rules.sh REFUSES TO REPORT SUCCESS when it has checked nothing: zero
# manifests discovered, zero suites discovered, a `rule_files` path that does not
# resolve (promtool warns, loads zero rules, and passes every assertion), a
# mutation the suites no longer catch. Those are the states a green pipeline
# cannot tell apart from real coverage, so they need a test that asserts the
# failure. It stubs promtool, so it needs neither the binary nor the heavy image.
#
# Both are unfiltered by path on purpose: this pipeline is two steps on one
# already-cached image, so running it everywhere is free, and a guard whose test
# only runs when someone remembers to touch the right directory is not a guard.
#
# Only in homelab (scripts/ci/ lives here); the file is not deployed to the
# mirrored repos, so it does not run on their PRs. The test is offline (no token,
# no network, no cluster) — the ci-claude image just provides sh + jq, and it is
# already pulled by pr-review.yml on the same event, so the runner has it cached.
# mirrored repos, so it does not run on their PRs. All tests here are offline (no
# token, no network, no cluster) — the ci-claude image just provides sh + jq, and
# it is already pulled by pr-review.yml on the same event, so the runner has it
# cached.
when:
- event: pull_request
# `depends_on: []` on every step so they run in parallel and one failure does not hide
# the other. Same reasoning as the note above `steps:` in ci-manifests-test.yml.
steps:
- name: model-selection-tests
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
commands:
- sh scripts/ci/test-model-selection.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
commands:
- sh scripts/ci/test-yaml-citations.sh
-3
View File
@@ -4,9 +4,6 @@ when:
branch: master
path:
- "ansible/roles/netbird-server/**"
# install-netbird-server.yml also applies the netbird-proxy-watchdog role,
# so changes to it must redeploy this host too.
- "ansible/roles/netbird-proxy-watchdog/**"
- "ansible/plays/install-netbird-server.yml"
- ".woodpecker/deploy-netbird-server.yml"
- event: deployment
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -62,7 +62,7 @@ steps:
- bash scripts/ci/make-diag-kubeconfig.sh
- name: issue-fix-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -65,7 +65,7 @@ when:
steps:
- name: pr-review-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: master
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+6 -6
View File
@@ -4,7 +4,7 @@
# script (structural checks, no model), then consolidate (wiki-consolidate
# agent: verifies a cohort of notes against sources, repos and the live
# cluster and edits them). The pipeline commits and pushes as Claude Bot; the
# push webhook then refreshes the wiki-index service.
# push webhook then refreshes the wiki service.
#
# The agents address the vault as ~/Notes and the repos as ~/source/<repo>, so
# each agent step links those into the workspace instead of rewriting them.
@@ -17,7 +17,7 @@ when:
steps:
- name: checkout-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
pull: true
environment:
WIKI_BOT_TOKEN:
@@ -54,7 +54,7 @@ steps:
- bash scripts/ci/make-diag-kubeconfig.sh
- name: distill
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
@@ -89,14 +89,14 @@ steps:
-p
- name: audit
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
pull: true
commands:
# Findings are report lines in output/, not a pipeline failure.
- python3 -B notes/bin/wiki_audit.py --vault "$$PWD/notes"
- name: consolidate
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
@@ -145,7 +145,7 @@ steps:
exit $$rc
- name: push-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.283
pull: true
environment:
WIKI_BOT_TOKEN:
+7
View File
@@ -0,0 +1,7 @@
# Contributing
## Citing other YAML from a comment
- Mark the block you cite with a comment line `# anchor: <name>` (kebab-case, unique in that file). If the block is inside a `|` block scalar, put the marker just above the key, never inside the scalar.
- Cite it as `path.yaml#<name>`, with the path relative to the citing file's directory or to the repo root. Never cite `path.yaml:<line>`, because line numbers go stale on any edit above them.
- `scripts/ci/yaml-citations.sh` enforces this in `ci-manifests-test.yml`: it fails on line-number citations and on citations whose file or anchor does not exist.
+70
View File
@@ -0,0 +1,70 @@
# homelab
GitOps source for the Renner homelab cluster. Flux applies `master` to
production, so every merge is a production change. Changes go in by pull
request only; see [CONTRIBUTING.md](CONTRIBUTING.md) for YAML conventions.
## Change rules that protect Home Assistant
Owner decision (REN-240, REN-243). Home Assistant runs the house, so it
gets two rules on top of normal review.
### 1. Disruptive work runs only 02:00–05:00 Europe/Berlin
Planned work that can restart Home Assistant or disrupt the cluster is
merged and applied only between **02:00 and 05:00 Europe/Berlin**. That
covers:
- node drain, node reboot, Talos upgrade
- CNI (Cilium, Multus) and cluster-wide network policy
- storage (Rook/Ceph, CSI, snapshot controller, CNPG operator)
- Flux core (`kubernetes/clusters/*/flux-system/`, Flux controllers)
- Authentik
- OpenBao, including secret rotation that restarts consumers
- anything in `kubernetes/apps/home-assistant/`
A PR in this list states in its description that it waits for the window.
Flux applies on merge, so "wait for the window" means **merge** in the
window, not just apply. If unsure whether a change is disruptive, treat it
as disruptive.
Not covered: Home Assistant image bumps by Flux image automation. They stay
fully automatic by owner decision. If one breaks Home Assistant, roll it
back with [RUNBOOK-home-assistant-image-rollback.md](kubernetes/RUNBOOK-home-assistant-image-rollback.md).
An incident rollback is not planned work; the owner decides its timing.
### 2. HA-impact review before merge
Every PR that touches `kubernetes/apps/home-assistant/`, or makes a
cluster-wide disruptive change from the list above, needs an **HA-impact
review** before merge: a named reviewer states in the PR what Home
Assistant will notice (restart, lost integration, lost history, none) and
approves.
- Reviewer: Hestia, the Home Assistant maintainer.
- Until Hestia exists: the owner does the HA-impact review.
This is in addition to the normal review and merge by Argus. Argus does
not merge such a PR without the HA-impact approval.
## Agent runtime images
Pod images for the Paperclip Kubernetes sandbox (REN-284). Each is the stock
`ghcr.io/paperclipai/agent-runtime-claude` (pinned by digest) plus a tool
set. Base user (1000, non-root), entrypoint and cmd are unchanged; the
images run with a read-only root FS. Source in `ci/images/agent-runtime-*`,
built by `.woodpecker/build-agent-runtime.yml` (re-run alone: deployment to
target `build-agent-runtime`), versions bumped by Renovate. Both also
carry `claude-agent-acp` for the ACP engine, pinned and patched like
Paperclip ships it, SDK override included
(`ci/images/agent-runtime-common/`); bump it by hand
together with Paperclip. All tools are on the default `PATH`.
| Image | `imageRegistry` | Adds |
|---|---|---|
| `gitea.renner.uno/konairius/agent-runtime-go/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-go` | Go toolchain, golangci-lint |
| `gitea.renner.uno/konairius/agent-runtime-ops/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-ops` | kubectl, flux, kustomize, helm |
The plugin keeps the name `agent-runtime-claude:v1` and swaps only host and
org, hence the nested path. Every build also pushes a date tag
(`:YYYY-MM-DD`) as the rollback point. `konairius` is public: no pull secret.
+6 -7
View File
@@ -1,5 +1,5 @@
---
# Deploy a NetBird reverse-proxy HA cluster member (a second proxy host).
# Deploy the NetBird reverse-proxy HA cluster members (the public edge).
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.yml
#
# Prerequisites:
@@ -7,12 +7,11 @@
# listed under [netbird_proxy] in inventory/pve_hosts.yml. Docker is
# installed by the (Arch-aware) docker-install role.
# - DNS A/AAAA for the host's own name (e.g. proxy-1.renner.uno) -> its IP.
# - Uses the existing vault_netbird_proxy_token (shared with VPS-1) — no new
# token to generate; a shared token + same NB_PROXY_DOMAIN forms the cluster.
# - AFTER deploy + verify: add this host's IP to the proxy domain so traffic
# round-robins across the cluster. Repoint proxy.renner.uno away from its
# CNAME to netbird.renner.uno (management host) to A/AAAA records listing
# BOTH proxy IPs (VPS-1 + this host).
# - Uses the existing vault_netbird_proxy_token (shared by every member) — no
# new token to generate; a shared token + same NB_PROXY_DOMAIN forms the
# cluster.
# - AFTER deploy + verify: add this host's A/AAAA to proxy.renner.uno so
# traffic round-robins across the cluster.
- name: Deploy NetBird reverse-proxy cluster member
hosts: netbird_proxy
-1
View File
@@ -18,4 +18,3 @@
- common
- docker-install
- netbird-server
- netbird-proxy-watchdog
+4 -4
View File
@@ -43,10 +43,10 @@
#
# WHAT IS NOT, and must not be added:
#
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on both
# the server and the proxies, all Ansible
# templates — but the management server does not
# validate it from any of them. It is registered
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on the
# proxies, an Ansible template — but the
# management server does not validate it from
# any file. It is registered
# in the management DATABASE. Rotating it changes
# only what the proxies present, so every proxy
# is locked out: "proxy auth failed: too many
@@ -1,5 +1,5 @@
---
# Self-healing watchdog for the NetBird reverse proxy (VPS-1 and proxy-1).
# Self-healing watchdog for the NetBird reverse proxy on the proxy hosts.
#
# Why it exists: the proxy's embedded NetBird client pins each proxied
# domain's route to a specific routing-peer identity. The netbird-ingress
@@ -20,9 +20,7 @@ netbird_watchdog_probe_hosts:
- home.renner.uno
- immich.renner.uno
# Address the probe connects to. 127.0.0.1 reaches the local proxy on both
# members: VPS-1 routes it via Traefik's HostSNI(*) TCP passthrough, proxy-1
# binds :443 directly.
# Address the probe connects to. The proxy binds :443 on the host directly.
netbird_watchdog_probe_address: 127.0.0.1
netbird_watchdog_container: netbird-proxy
@@ -1,7 +1,7 @@
---
# Deploys the reverse-proxy watchdog (see defaults/main.yml for why).
- name: Ensure curl is installed (probe dependency; proxy-1 is Arch, VPS-1 Debian)
- name: Ensure curl is installed (probe dependency)
ansible.builtin.package:
name: curl
state: present
@@ -1,8 +1,8 @@
---
# NetBird reverse-proxy HA cluster member (proxy-only host, e.g. proxy-1).
# Joins the same proxy cluster as the VPS-1 proxy: identical NB_PROXY_DOMAIN
# + the same token. NetBird load-balances/fails the cluster over via DNS
# round-robin on the proxy domain (see plays/install-netbird-proxy.yml).
# Every member uses the same NB_PROXY_DOMAIN + token. NetBird load-balances/
# fails the cluster over via DNS round-robin on the proxy domain (see
# plays/install-netbird-proxy.yml).
# MUST match the netbird-server role's netbird_version — the reverse-proxy
# release line has to track the management server it enrolls against.
@@ -11,11 +11,13 @@ netbird_version: "0.73.2"
# Management server this proxy enrolls against (public API, TLS).
netbird_server_host: netbird.renner.uno
# Proxy domain — MUST be identical across every cluster member.
# Proxy domain — MUST be identical across every cluster member, and must NOT
# be a subdomain of the management domain (netbird.renner.uno) per NetBird
# docs (TLS/routing conflicts).
netbird_proxy_domain: "proxy.renner.uno"
# Reuse VPS-1's proxy token — a shared NB_PROXY_TOKEN across members with the
# same NB_PROXY_DOMAIN is a valid HA cluster, so no separate token is needed.
# A shared NB_PROXY_TOKEN across members with the same NB_PROXY_DOMAIN is a
# valid HA cluster, so one token serves every member.
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
# Relay auth secret — MUST be identical across every relay instance and the
@@ -1,8 +1,7 @@
# NetBird reverse-proxy — HA cluster member on a proxy-only host.
# Just the reverse-proxy container, bound directly to :443. No Traefik:
# unlike VPS-1 (where the proxy shares :443 with management/signal/relay via
# SNI routing), this host runs nothing else, so the proxy owns the port and
# terminates TLS + answers its own tls-alpn-01 ACME challenges directly.
# Just the reverse-proxy container, bound directly to :443. No Traefik: the
# proxy owns the port and terminates TLS + answers its own tls-alpn-01 ACME
# challenges directly.
services:
proxy:
image: netbirdio/reverse-proxy:{{ netbird_version }}
@@ -23,9 +23,3 @@ netbird_datastore_key: "{{ vault_netbird_datastore_key }}"
# Relay secret for WebSocket relay authentication
netbird_relay_secret: "{{ vault_netbird_relay_secret }}"
# Reverse proxy configuration
# IMPORTANT: Proxy domain must NOT be a subdomain of the management domain
# (netbird.renner.uno) per NetBird docs - causes TLS/routing conflicts
netbird_proxy_domain: "proxy.renner.uno"
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
@@ -16,7 +16,3 @@
- name: Restart netbird coturn
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d --force-recreate coturn
- name: Restart netbird proxy
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d --force-recreate proxy
+68 -9
View File
@@ -36,7 +36,6 @@
- /opt/netbird/management
- /opt/netbird/signal
- /opt/netbird/letsencrypt
- /opt/netbird/proxy/certs
- name: Deploy docker-compose.yml
ansible.builtin.template:
@@ -59,23 +58,83 @@
mode: "0644"
notify: Restart netbird coturn
- name: Deploy proxy.env
ansible.builtin.template:
src: proxy.env.j2
dest: /opt/netbird/proxy.env
mode: "0644"
notify: Restart netbird proxy
# The public edge is the proxy hosts (plays/install-netbird-proxy.yml); this
# host runs no reverse proxy. The tasks below remove the one it used to run,
# its watchdogs first so they cannot restart it mid-removal, and can go once
# the role has run against every host that ever carried it.
- name: Check for reverse-proxy watchdog units
ansible.builtin.stat:
path: "/etc/systemd/system/{{ item }}"
loop:
- netbird-proxy-watchdog.timer
- netbird-proxy-flood-watchdog.service
register: _proxy_watchdog_units
- name: Stop and disable reverse-proxy watchdogs
ansible.builtin.systemd:
name: "{{ item.item }}"
state: stopped
enabled: false
loop: "{{ _proxy_watchdog_units.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
- name: Remove reverse-proxy watchdog units and scripts
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/system/netbird-proxy-watchdog.timer
- /etc/systemd/system/netbird-proxy-watchdog.service
- /etc/systemd/system/netbird-proxy-flood-watchdog.service
- /usr/local/bin/netbird-proxy-watchdog
- /usr/local/bin/netbird-proxy-flood-watchdog
- /run/netbird-proxy-watchdog
register: _proxy_watchdog_files
- name: Reload systemd after removing the watchdog units
ansible.builtin.systemd:
daemon_reload: true
when: _proxy_watchdog_files.changed
- name: Pull NetBird container images
ansible.builtin.shell: |
cd /opt/netbird && docker compose pull
changed_when: false
# --remove-orphans removes containers of services no longer in the compose
# file (the reverse proxy).
- name: Start NetBird containers
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d
cd /opt/netbird && docker compose up -d --remove-orphans
register: docker_compose_result
changed_when: "'Started' in docker_compose_result.stdout or 'Creating' in docker_compose_result.stdout"
changed_when: >-
'Started' in docker_compose_result.stdout or
'Creating' in docker_compose_result.stdout or
'Removed' in docker_compose_result.stdout or
'Removed' in docker_compose_result.stderr
- name: Remove reverse-proxy volumes
ansible.builtin.shell: |
set -eu
for v in netbird_netbird_proxy_certs netbird_netbird_proxy_data netbird_netbird_proxy_state; do
if [ -n "$(docker volume ls -q --filter "name=^${v}$")" ]; then
docker volume rm "$v"
echo "removed $v"
fi
done
register: _proxy_volumes
changed_when: "'removed' in _proxy_volumes.stdout"
- name: Remove reverse-proxy config and state
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /opt/netbird/proxy.env
- /opt/netbird/proxy
- /opt/netbird/proxy-state
# The management store has no cluster-side backup. Pulling one put a root
# key on this host that the cluster held, and this server's own state is
@@ -187,10 +187,9 @@ services:
- "traefik.http.routers.netbird-management.service=netbird-management"
- "traefik.http.services.netbird-management.loadbalancer.server.port=33073"
- "traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c"
# gRPC reverse-proxy service — remote reverse-proxy instances (e.g.
# proxy-1) connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443.
# The local proxy uses management:33073 directly, so it never needed
# this route; remote ones get a 404 without it. Same backend (h2c 33073).
# gRPC reverse-proxy service — the proxy hosts (proxy-1, proxy-2)
# connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443 and get
# a 404 without it. Same backend (h2c 33073).
- "traefik.http.routers.netbird-proxyservice.rule=Host(`{{ netbird_server_host }}`) && PathPrefix(`/management.ProxyService/`)"
- "traefik.http.routers.netbird-proxyservice.entrypoints=websecure"
- "traefik.http.routers.netbird-proxyservice.tls=true"
@@ -221,37 +220,6 @@ services:
max-size: "500m"
max-file: "2"
# Reverse Proxy Service (per docs.netbird.io/selfhosted/migration/enable-reverse-proxy)
proxy:
image: netbirdio/reverse-proxy:{{ netbird_version }}
container_name: netbird-proxy
restart: unless-stopped
networks:
- netbird
depends_on:
- management
env_file:
- ./proxy.env
volumes:
- netbird_proxy_certs:/certs
- /opt/netbird/proxy-state:/var/lib/netbird
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.proxy-passthrough.entrypoints=websecure"
- "traefik.tcp.routers.proxy-passthrough.rule=HostSNI(`*`)"
- "traefik.tcp.routers.proxy-passthrough.tls.passthrough=true"
- "traefik.tcp.routers.proxy-passthrough.service=proxy-tls"
- "traefik.tcp.routers.proxy-passthrough.priority=1"
- "traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443"
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
networks:
netbird:
driver: bridge
volumes:
netbird_proxy_certs:
@@ -1,8 +0,0 @@
NB_PROXY_DOMAIN={{ netbird_proxy_domain }}
NB_PROXY_TOKEN={{ netbird_proxy_token }}
NB_PROXY_MANAGEMENT_ADDRESS=http://management:33073
NB_PROXY_ALLOW_INSECURE=true
NB_PROXY_ADDRESS=:8443
NB_PROXY_ACME_CERTIFICATES=true
NB_PROXY_ACME_CHALLENGE_TYPE=tls-alpn-01
NB_PROXY_CERTIFICATE_DIRECTORY=/certs
@@ -0,0 +1,66 @@
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef14627011e02b30 100644
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2922,9 +2922,19 @@ export class ClaudeAcpAgent {
amount: message.total_cost_usd,
currency: "USD",
},
- ...(message.origin && {
- _meta: { "_claude/origin": message.origin },
- }),
+ _meta: {
+ ...(message.origin && { "_claude/origin": message.origin }),
+ // ACP's core Usage shape exposes context occupancy and
+ // cost, but not the billable input/output split. Keep the
+ // SDK's bounded aggregate in extension metadata so ACPX can
+ // normalize it without persisting a raw provider message.
+ usage: {
+ input_tokens: message.usage.input_tokens,
+ output_tokens: message.usage.output_tokens,
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
+ },
+ },
},
});
}
@@ -5311,6 +5321,10 @@ export class ClaudeAcpAgent {
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
+ // Paperclip Runner owns the complete session context. Its isolated
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -5318,7 +5332,9 @@ export class ClaudeAcpAgent {
includePartialMessages: true,
forwardSubagentText,
mcpServers: {
- ...(userProvidedOptions?.mcpServers || {}),
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
+ ? {}
+ : (userProvidedOptions?.mcpServers || {})),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
@@ -5329,6 +5345,16 @@ export class ClaudeAcpAgent {
allowDangerouslySkipPermissions: ALLOW_BYPASS,
permissionMode: initialPermissionMode,
canUseTool: this.canUseTool(sessionId),
+ // The runner pins this actual MCP endpoint before provider launch.
+ // Exempt only task delivery/control tools at the SDK dispatch boundary,
+ // not arbitrary provider permission metadata or MCP name prefixes.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && {
+ allowedTools: mcpServers.paperclip?.type === "http"
+ && mcpServers.paperclip.url === process.env.PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL
+ ? ["paperclip_finish", "paperclip_block", "read_current_wake_comments", "request_human_input"]
+ .map((tool) => `mcp__paperclip__${tool}`)
+ : [],
+ }),
// Forward MCP elicitation requests onto ACP elicitation. Only attached
// when the client advertised support, so non-supporting clients keep the
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
@@ -0,0 +1,9 @@
{
"private": true,
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.73.0"
},
"overrides": {
"@anthropic-ai/claude-agent-sdk": "0.3.280"
}
}
+60
View File
@@ -0,0 +1,60 @@
# Paperclip agent runtime + Go toolchain (REN-284 / REN-286). Runs as the pod
# image of the `k8s-go` sandbox environment.
#
# Every tool comes from its upstream image, pinned by tag AND digest, so the
# dockerfile manager in Renovate bumps version and digest together. A curl +
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
#
# Both binaries are static; they run on the Ubuntu 22.04 base unchanged.
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS go
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS golangci-lint
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
# digest current but cannot order commit tags; a newer base is a manual bump.
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
COPY --from=go /usr/local/go /usr/local/go
COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
# go and gofmt also in /usr/local/bin: a process started with the default PATH
# (not the image ENV) still finds them.
USER root
RUN ln -s /usr/local/go/bin/go /usr/local/bin/go \
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
USER 1000:1000
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
# Paperclip also overrides the SDK (package.json pnpm overrides:
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
# older SDK. So install from a package.json with the same override into a
# fixed prefix, and link the binary into /usr/local/bin.
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
USER root
RUN cd /opt/claude-agent-acp \
&& npm install --omit=dev --no-audit --no-fund \
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
&& rm /tmp/claude-agent-acp.patch \
&& npm cache clean --force
USER 1000:1000
# The sandbox pod has a read-only root FS. Writable mounts: $HOME
# (/home/paperclip, 1Gi), $HOME/.cache (1Gi), /tmp (2Gi), /workspace.
# - GOPATH (module cache) goes to /tmp, the biggest one.
# - GOCACHE and the golangci-lint cache keep their default under $HOME/.cache.
# - GOTOOLCHAIN=local: a go.mod asking for a newer Go fails clearly instead of
# downloading a toolchain through the egress allow-list.
ENV PATH=/usr/local/go/bin:$PATH \
GOPATH=/tmp/go \
GOTOOLCHAIN=local
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
# The PATH check uses the base image's default PATH, not the ENV above.
RUN go version && golangci-lint version \
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
sh -ec 'for t in go gofmt golangci-lint claude-agent-acp; do command -v "$t"; done'
+51
View File
@@ -0,0 +1,51 @@
# Paperclip agent runtime + cluster tools (REN-284 / REN-286). Runs as the pod
# image of the `k8s-ops` sandbox environment.
#
# Every tool comes from its upstream image, pinned by tag AND digest, so the
# dockerfile manager in Renovate bumps version and digest together. A curl +
# sha256 pin (as in ci/images/k8s) would break on every Renovate bump.
#
# kubectl and flux match the ci-k8s image. All four binaries are static; they
# run on the Ubuntu 22.04 base unchanged.
FROM registry.k8s.io/kubectl:v1.36.5@sha256:0667e3f3e6ef435d7cd4c16684244703ebd67314c17511c2a9f87ea44f6cdf6b AS kubectl
FROM ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 AS flux
FROM registry.k8s.io/kustomize/kustomize:v5.8.1@sha256:899fcd3bc898160e62bcaf82932b0cb29ba38d16272353db2e7acbba82129429 AS kustomize
FROM alpine/helm:4.3.0@sha256:a6cf54599ccb99d90cf0712b30f03fdb3cab062e6b94e0418cc4db7e8a1464b2 AS helm
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
# digest current but cannot order commit tags; a newer base is a manual bump.
FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518742e33ce2f@sha256:33ad285f9476b8637d6d940c08d577797030125675bdf4f497a8ed1f79261c8b
COPY --from=kubectl /bin/kubectl /usr/local/bin/kubectl
COPY --from=flux /usr/local/bin/flux /usr/local/bin/flux
COPY --from=kustomize /app/kustomize /usr/local/bin/kustomize
COPY --from=helm /usr/bin/helm /usr/local/bin/helm
# ACP server the Paperclip ACP engine starts in the pod (REN-302). Version and
# patch match what Paperclip ships: packages/adapters/claude-local/package.json
# pins 0.73.0 and applies patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
# (isolated context, task-tool allow-list, usage metadata). Not bumped by
# Renovate on purpose: bump only together with Paperclip, and replace the patch.
# Paperclip also overrides the SDK (package.json pnpm overrides:
# claude-agent-sdk 0.3.280); a plain `npm install -g` ignores that and pulls an
# older SDK. So install from a package.json with the same override into a
# fixed prefix, and link the binary into /usr/local/bin.
COPY agent-runtime-common/claude-agent-acp.package.json /opt/claude-agent-acp/package.json
COPY agent-runtime-common/claude-agent-acp-0.73.0.patch /tmp/claude-agent-acp.patch
USER root
RUN cd /opt/claude-agent-acp \
&& npm install --omit=dev --no-audit --no-fund \
&& git -C node_modules/@agentclientprotocol/claude-agent-acp apply /tmp/claude-agent-acp.patch \
&& npm ls @anthropic-ai/claude-agent-sdk | grep -q 'claude-agent-sdk@0.3.280' \
&& ln -s /opt/claude-agent-acp/node_modules/.bin/claude-agent-acp /usr/local/bin/claude-agent-acp \
&& rm /tmp/claude-agent-acp.patch \
&& npm cache clean --force
USER 1000:1000
# Read-only root FS: kubectl (~/.kube/cache) and helm (~/.cache, ~/.config)
# write only under $HOME, which the sandbox pod mounts writable. No ENV needed.
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
RUN kubectl version --client && flux version --client && kustomize version && helm version \
&& env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
sh -ec 'for t in kubectl flux kustomize helm claude-agent-acp; do command -v "$t"; done'
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+55
View File
@@ -0,0 +1,55 @@
# Paperclip server image (REN-401)
Upstream `paperclipai/paperclip` at the commit in `upstream.env`, plus every
`*.patch` in this directory. Built by `.woodpecker/build-paperclip.yml`,
pushed as `gitea.renner.uno/konairius/paperclip:build.<unix-ts>`, rolled out
by Flux image automation (`kubernetes/infrastructure/image-automation/paperclip.yaml`).
## Why
k8s sandbox runs fail: the run path never sets the lease `remoteCwd`, so file
sync into the pod fails (paperclipai/paperclip#13864). The fix is
paperclipai/paperclip#13733, open and not merged.
`pr-13733-lease-remote-cwd.patch` is that PR's diff (head `5f320bf6`).
#13733 covers only the run path. Adapter environment tests (binding an agent
to a k8s environment) sync managed config before any workspace is realized,
so the lease still has no `remoteCwd` and the Claude test fails with
`claude_managed_config_dir_failed` (REN-409). The fix is
paperclipai/paperclip#14769, also open: the plugin sets `remoteCwd:
/workspace` when it acquires or resumes a lease.
`pr-14769-probe-remote-cwd.patch` is that PR's diff (head `10dc7371`).
## Files
- `upstream.env` — pinned commit and the version string stamped into the
image. Bump both together.
- `prepare.sh` — fetches the commit, applies the patches, writes
`Dockerfile.kaniko` (upstream's `COPY --parents` line split into plain
`COPY` lines; kaniko does not support `--parents`), and adds the build of
the Kubernetes sandbox provider plugin to the `build` stage (REN-405):
upstream's `production` target ships only its source, so the plugin fails
with "no longer exposes a Paperclip manifest". The build fails if
`dist/manifest.js` is missing.
- `*.patch` — applied in name order with `git apply`.
## Moving to a newer upstream commit
Set `PAPERCLIP_COMMIT` to the new master commit and `PAPERCLIP_VERSION` to
`git describe --tags --match 'v*' --long <commit>`, then run
`sh prepare.sh /tmp/src`: it fails if a patch no longer applies.
## Exit: when #13733 and #14769 are merged upstream
Renovate cannot watch an upstream PR, so this is a manual step. Once
upstream nightly contains both fixes:
1. `kubernetes/infrastructure/image-automation/paperclip.yaml`: back to
`ghcr.io/paperclipai/paperclip` with `filterTags.pattern: '^nightly$'`
(the version before REN-401 in git history).
2. `renovate.json`: swap `gitea.renner.uno/konairius/paperclip` back to
`ghcr.io/paperclipai/paperclip` in the image-automation exclusion list.
3. Delete this directory and `.woodpecker/build-paperclip.yml`.
Image automation then rewrites `kubernetes/apps/paperclip/paperclip.yaml`
to the nightly digest by itself.
@@ -0,0 +1,93 @@
diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts
index f3d1fc659..812cae357 100644
--- a/server/src/__tests__/environment-run-orchestrator.test.ts
+++ b/server/src/__tests__/environment-run-orchestrator.test.ts
@@ -430,6 +430,51 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
expect(result.persistedExecutionWorkspace).toEqual(updatedEw);
});
+ it("persists the realized cwd as remoteCwd when realization returns no workspaceRealization payload", async () => {
+ const runtime = makeMockRuntime({
+ realizeWorkspace: vi.fn().mockResolvedValue({
+ cwd: "/workspace",
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
+ }),
+ } as Partial<EnvironmentRuntimeService>);
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
+
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
+
+ await orchestrator.realizeForRun(
+ makeRealizeInput({ environment: makeEnvironment("sandbox") }),
+ );
+
+ expect(mockUpdateLeaseMetadata).toHaveBeenCalledWith(
+ "lease-1",
+ expect.objectContaining({ remoteCwd: "/workspace" }),
+ );
+ // Nothing to record about the realization shape, so the execution workspace
+ // row is left untouched.
+ expect(mockUpdateExecutionWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("does not rewrite lease metadata when the realized cwd already matches the lease", async () => {
+ const runtime = makeMockRuntime({
+ realizeWorkspace: vi.fn().mockResolvedValue({
+ cwd: "/workspace",
+ metadata: { provider: "kubernetes", remoteCwd: "/workspace" },
+ }),
+ } as Partial<EnvironmentRuntimeService>);
+ mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "local", environmentId: "env-1", leaseId: "lease-1" });
+
+ const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
+
+ await orchestrator.realizeForRun(
+ makeRealizeInput({
+ environment: makeEnvironment("sandbox"),
+ lease: makeLease({ metadata: { remoteCwd: "/workspace" } }),
+ }),
+ );
+
+ expect(mockUpdateLeaseMetadata).not.toHaveBeenCalled();
+ });
+
it("runs a remote provision command after workspace realization when configured", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts
index 97cca667e..e76f64913 100644
--- a/server/src/services/environment-run-orchestrator.ts
+++ b/server/src/services/environment-run-orchestrator.ts
@@ -485,16 +485,29 @@ export function environmentRunOrchestrator(
}
// Step 3: Persist realization metadata on lease and execution workspace
- if (Object.keys(workspaceRealization).length > 0) {
+ const hasWorkspaceRealization = Object.keys(workspaceRealization).length > 0;
+ // A driver that realizes the workspace reports the directory it prepared
+ // through the realization `cwd`. Persist it on the lease as `remoteCwd`, so
+ // every later step that receives only the lease — native file sync, a
+ // resumed lease, a second run against the same lease — confines itself to
+ // the same root. Without it a driver whose realization returns no
+ // `workspaceRealization` payload leaves the lease without a root at all,
+ // and the sync step fails even though realization succeeded.
+ const currentRemoteCwd =
+ typeof lease.metadata?.remoteCwd === "string" ? lease.metadata.remoteCwd.trim() : "";
+ const realizedRemoteCwd =
+ realizedWorkspaceCwd && realizedWorkspaceCwd !== currentRemoteCwd ? realizedWorkspaceCwd : null;
+ if (hasWorkspaceRealization || realizedRemoteCwd) {
const nextLeaseMetadata = {
...(lease.metadata ?? {}),
- workspaceRealization,
+ ...(hasWorkspaceRealization ? { workspaceRealization } : {}),
+ ...(realizedRemoteCwd ? { remoteCwd: realizedRemoteCwd } : {}),
};
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, nextLeaseMetadata);
if (updatedLease) {
lease = updatedLease;
}
- if (persistedExecutionWorkspace) {
+ if (hasWorkspaceRealization && persistedExecutionWorkspace) {
const updatedEw = await executionWorkspacesSvc.update(persistedExecutionWorkspace.id, {
metadata: {
...(persistedExecutionWorkspace.metadata ?? {}),
@@ -0,0 +1,86 @@
diff --git a/packages/plugins/sandbox-providers/kubernetes/README.md b/packages/plugins/sandbox-providers/kubernetes/README.md
index a51c6ecee60e..72618cb11296 100644
--- a/packages/plugins/sandbox-providers/kubernetes/README.md
+++ b/packages/plugins/sandbox-providers/kubernetes/README.md
@@ -140,6 +140,8 @@ Every agent pod is:
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, configured FQDNs/CIDRs).
+The lease starts with `/workspace` as the native file-sync confinement root. Adapter environment tests can upload managed config before the server realizes a workspace. When resuming a lease, the provider keeps a previously recorded `remoteCwd` so a custom realized workspace is not replaced with the default.
+
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Job, so a single `kubectl delete job …` cascades cleanly to the Pod and Secret.
## Optional Kata-FC microVM isolation
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
index 8ae47de8ecac..98016385da05 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
@@ -454,6 +454,9 @@ const plugin = definePlugin({
secretName,
phase: "Pending",
backend: config.backend,
+ // Adapter environment probes sync managed assets before workspace
+ // realization. Give native sync the pod's mounted confinement root now.
+ remoteCwd: "/workspace",
scopedNetworkPolicyName,
scopedNetworkEgress,
// Native file sync streams over a pod exec; only the sandbox-cr backend
@@ -529,6 +532,10 @@ const plugin = definePlugin({
secretName,
phase: check.phase,
backend: leaseBackend,
+ remoteCwd:
+ typeof params.leaseMetadata?.remoteCwd === "string" && params.leaseMetadata.remoteCwd.trim()
+ ? params.leaseMetadata.remoteCwd.trim()
+ : "/workspace",
scopedNetworkPolicyName:
typeof params.leaseMetadata?.scopedNetworkPolicyName === "string"
? params.leaseMetadata.scopedNetworkPolicyName
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/types.ts b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
index 5626daa93846..ef3d0d889a4c 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/types.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/types.ts
@@ -90,6 +90,8 @@ export interface KubernetesLeaseMetadata {
phase: "Pending" | "Running" | "Succeeded" | "Failed";
/** Which backend provisioned this lease. */
backend: "sandbox-cr" | "job";
+ /** Native sync confinement root; present before workspace realization for probes. */
+ remoteCwd: string;
scopedNetworkPolicyName: string | null;
scopedNetworkEgress: {
allowFqdns: string[];
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
index 7ad0ca4807e5..4c23410d4d15 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
@@ -80,6 +80,7 @@ describe("onEnvironmentResumeLease", () => {
secretName: "pc-abc-env",
phase: "Running",
backend: "sandbox-cr",
+ remoteCwd: "/workspace",
resumedLease: true,
// sandbox-cr has a pod-exec channel, so native file sync stays enabled.
nativeFileSyncUnsupported: false,
@@ -87,6 +88,22 @@ describe("onEnvironmentResumeLease", () => {
);
});
+ it("keeps a custom realized cwd when resuming a sandbox lease", async () => {
+ h.clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")) },
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
+ };
+ const lease = await plugin.definition.onEnvironmentResumeLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata({ remoteCwd: "/workspace/custom" }),
+ });
+ expect(lease.metadata?.remoteCwd).toBe("/workspace/custom");
+ });
+
it("flags a resumed job-backend lease as native-sync-unsupported so the server keeps the base64 fallback", async () => {
h.clients = {
batch: {
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Fetch upstream Paperclip at the pinned commit, apply our patches and make
# the Dockerfile buildable with kaniko. Usage: prepare.sh <target-dir>
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
. "$HERE/upstream.env"
OUT=$1
rm -rf "$OUT"
git init -q "$OUT"
cd "$OUT"
git fetch -q --depth 1 https://github.com/paperclipai/paperclip.git "$PAPERCLIP_COMMIT"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PAPERCLIP_COMMIT"
for p in "$HERE"/*.patch; do
echo "applying $(basename "$p")"
git apply "$p"
done
# kaniko does not know `COPY --parents` (BuildKit only). Upstream uses it for
# one line: every sandbox provider's package.json. Replace it with one plain
# COPY per provider directory, which copies the same files to the same place.
grep -c -- '^COPY --parents packages/plugins/sandbox-providers/\./\*/package.json packages/plugins/sandbox-providers/$' Dockerfile | grep -qx 1
awk '
/^COPY --parents packages\/plugins\/sandbox-providers\// {
while (("ls -d packages/plugins/sandbox-providers/*/package.json" | getline f) > 0) {
d = f; sub(/package.json$/, "", d)
print "COPY " f " " d
}
next
}
{ print }
' Dockerfile > Dockerfile.kaniko
if grep -q -- '--parents' Dockerfile.kaniko; then
echo "Dockerfile.kaniko still uses COPY --parents" >&2
exit 1
fi
# Build the Kubernetes sandbox provider into the image (REN-405). Upstream's
# `production` target ships only its source; the plugin loader needs
# dist/manifest.js. Same commands as upstream's `cloud-plugins` stage, run in
# the `build` stage right after the server build, so production's
# `COPY --from=build /app /app` carries dist/ and node_modules along. No extra
# stage: kaniko would snapshot the whole build stage again for it.
export ANCHOR='RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)'
grep -c -F -x -- "$ANCHOR" Dockerfile.kaniko | grep -qx 1
K=packages/plugins/sandbox-providers/kubernetes
awk -v k="$K" '
{ print }
$0 == ENVIRON["ANCHOR"] {
print "RUN pnpm -C " k " install --ignore-workspace --no-lockfile \\"
print " && pnpm -C " k " build \\"
print " && test -f " k "/dist/manifest.js"
}
' Dockerfile.kaniko > Dockerfile.kaniko.new
mv Dockerfile.kaniko.new Dockerfile.kaniko
grep -q -- "test -f $K/dist/manifest.js" Dockerfile.kaniko
rm -rf .git
+7
View File
@@ -0,0 +1,7 @@
# Upstream paperclipai/paperclip commit this image is built from (REN-401).
# a027f76 is the master commit the nightly image ran when this was pinned, so
# the only change against that image is the patch next to this file.
# PAPERCLIP_VERSION is `git describe --tags --match 'v*' --long` of that
# commit, the value upstream stamps into its own images. Bump both together.
PAPERCLIP_COMMIT=a027f76a726e8a556674eded807c2de0e55f5cc0
PAPERCLIP_VERSION=v2026.916.0-219-ga027f76a7
-10
View File
@@ -1,10 +0,0 @@
# wiki-index: embedding index over the Notes repo's wiki/, fed from Gitea,
# queried by ~/Notes/bin/wiki_rank.py. Standard library only; runs unprivileged.
FROM python:3.13-slim
RUN useradd --system --no-create-home --shell /usr/sbin/nologin wikiindex
COPY wiki_index.py /opt/wiki_index.py
ENV PYTHONUNBUFFERED=1
USER wikiindex
ENTRYPOINT ["python3", "/opt/wiki_index.py"]
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env python3
"""Unit tests for wiki_index. Run: python3 -m unittest test_wiki_index"""
import hashlib
import hmac
import json
import tempfile
import unittest
from pathlib import Path
import wiki_index as wi
NOTE = """---
title: 'UDM intercepts DNS'
type: wiki
tags: [dns, unifi]
aliases: [udm dns interception, dig 1.1.1.1 wrong]
---
# UDM intercepts DNS
The UDM answers every UDP/53 query itself.
## Details
More.
"""
class ParseTests(unittest.TestCase):
def test_note_text(self):
title, text = wi.note_text("udm-dns", NOTE)
self.assertEqual(title, "UDM intercepts DNS")
self.assertIn("udm dns UDM intercepts DNS udm dns interception", text)
self.assertIn("dns unifi", text)
self.assertTrue(text.endswith("The UDM answers every UDP/53 query itself."))
self.assertLessEqual(len(text), wi.EMBED_CHARS)
def test_credentials_are_stripped(self):
original = wi.http
wi.http = lambda method, url, body=None, headers=None, timeout=60.0: (
{"auth": {"client_token": "t"}} if url.endswith("/login")
else {"data": {"data": {"gitea_token": "abc\n", "webhook_secret": " s3cret\n"}}})
original_sa = wi.SA_TOKEN
try:
with tempfile.TemporaryDirectory() as d:
wi.SA_TOKEN = Path(d) / "token"
wi.SA_TOKEN.write_text("jwt\n")
self.assertEqual(wi.Credentials().fetch(), {"gitea_token": "abc", "webhook_secret": "s3cret"})
finally:
wi.http, wi.SA_TOKEN = original, original_sa
def test_tree_paging_stops_on_short_last_page(self):
# Gitea: truncated=true on every page including the last, "tree": null past it.
pages = {
1: {"tree": [{"path": f"wiki/n{i}.md", "type": "blob", "sha": f"s{i}"} for i in range(1000)], "truncated": True, "total_count": 1192},
2: {"tree": [{"path": f"wiki/n{i}.md", "type": "blob", "sha": f"s{i}"} for i in range(1000, 1192)] + [{"path": "raw/x.md", "type": "blob", "sha": "r"}, {"path": "wiki/sub/y.md", "type": "blob", "sha": "y"}], "truncated": True, "total_count": 1192},
3: {"tree": None, "truncated": False, "total_count": 1192},
}
calls = []
original = wi.http
def fake_http(method, url, body=None, headers=None, timeout=60.0):
page = int(url.rsplit("page=", 1)[1])
calls.append(page)
return pages[page]
wi.http = fake_http
try:
tree = wi.Gitea("t").tree("abc")
finally:
wi.http = original
self.assertEqual(calls, [1, 2])
self.assertEqual(len(tree), 1192)
self.assertNotIn("raw/x.md", tree)
self.assertNotIn("wiki/sub/y.md", tree)
self.assertEqual(tree["wiki/n1191.md"], "s1191")
def test_signature(self):
body = b'{"ref":"refs/heads/master"}'
sig = hmac.new(b"s3cret", body, hashlib.sha256).hexdigest()
self.assertTrue(wi.verify_signature("s3cret", body, sig))
self.assertFalse(wi.verify_signature("other", body, sig))
self.assertFalse(wi.verify_signature("s3cret", body, ""))
class FakeGitea:
def __init__(self, head, tree, files):
self._head, self._tree, self.files = head, tree, files
self.raw_calls = []
def head(self):
return self._head
def tree(self, commit):
return self._tree
def raw(self, path, ref):
self.raw_calls.append(path)
return self.files[path]
class IndexTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "index.json"
self.original_embed = wi.embed
self.embed_calls = []
def fake_embed(texts, batch=2):
self.embed_calls.append(list(texts))
return [wi.unit([1.0, float(len(t) % 7)]) for t in texts]
wi.embed = fake_embed
def tearDown(self):
wi.embed = self.original_embed
self.tmp.cleanup()
def test_sync_is_incremental_and_persists(self):
files = {"wiki/udm-dns.md": NOTE, "wiki/ceph.md": "---\ntitle: Ceph\n---\n# Ceph\n\nOSD.\n", "wiki/_index.md": "---\ntitle: Wiki\n---\n"}
tree = {"wiki/udm-dns.md": "aaa", "wiki/ceph.md": "bbb", "wiki/_index.md": "ccc"}
g = FakeGitea("c1", tree, files)
idx = wi.Index(self.path)
s = idx.sync(g)
self.assertEqual((s["changed"], s["removed"], s["notes"]), (2, 0, 2))
self.assertEqual(set(idx.notes), {"udm-dns", "ceph"})
self.assertEqual(idx.notes["udm-dns"]["title"], "UDM intercepts DNS")
self.assertTrue(self.path.exists())
# head unchanged: nothing fetched
s = idx.sync(g)
self.assertEqual(s.get("skipped"), "head unchanged")
self.assertEqual(len(g.raw_calls), 2)
# one note changed, one removed, new head
g2 = FakeGitea("c2", {"wiki/udm-dns.md": "aaa2"}, {"wiki/udm-dns.md": NOTE.replace("itself", "itself!")})
s = idx.sync(g2)
self.assertEqual((s["changed"], s["removed"], s["notes"]), (1, 1, 1))
self.assertEqual(g2.raw_calls, ["wiki/udm-dns.md"])
# restart loads the persisted index
idx2 = wi.Index(self.path)
self.assertEqual(idx2.commit, "c2")
self.assertEqual(set(idx2.notes), {"udm-dns"})
def test_rank_orders_by_cosine(self):
idx = wi.Index(self.path)
idx.notes = {"a": {"sha": "1", "title": "A", "vector": wi.unit([1.0, 0.0])},
"b": {"sha": "2", "title": "B", "vector": wi.unit([0.0, 1.0])}}
wi.embed = lambda texts, batch=2: [wi.unit([0.2, 1.0])]
rows = idx.rank("q", k=5)
self.assertEqual([r["slug"] for r in rows], ["b", "a"])
self.assertGreater(rows[0]["score"], rows[1]["score"])
def test_status_reports_age_and_errors(self):
idx = wi.Index(self.path)
self.assertFalse(idx.status()["ready"])
g = FakeGitea("c1", {"wiki/a.md": "x"}, {"wiki/a.md": "---\ntitle: A\n---\n# A\n\nt\n"})
idx.sync(g)
st = idx.status()
self.assertTrue(st["ready"])
self.assertEqual(st["notes"], 1)
self.assertEqual(st["commit"], "c1")
class Broken(FakeGitea):
def head(self):
raise wi.ApiError("gitea down")
with self.assertRaises(wi.ApiError):
idx.sync(Broken("c1", {}, {}))
self.assertIn("gitea down", idx.status()["last_error"])
self.assertEqual(idx.status()["notes"], 1) # last good index kept
self.assertTrue(idx.status()["ready"]) # and still served
if __name__ == "__main__":
unittest.main()
-408
View File
@@ -1,408 +0,0 @@
#!/usr/bin/env python3
"""wiki-index: embedding index over the Notes repo's wiki/, fed from Gitea.
Source of truth is the notes repository on Gitea. The index holds, per wiki
note, the git blob sha, title and a bge-m3 vector of slug/title/aliases/tags/
first paragraph. A sync fetches the repo tree, embeds every note whose blob sha
changed (via wiki-embed, in-cluster) and drops notes that left the tree; it
runs at start, on every push webhook from Gitea, and every RESYNC_SECONDS.
HTTP:
POST /rank {"query": str, "k": int} -> {"rows": [{"slug","title","score"}], "index": {...}}
GET /health -> 200 always (liveness) with {"ready", "notes", "commit", "synced_at", "age_s", "syncing", "last_error"}
GET /ready -> 200 when an index exists, else 503 (readiness)
POST /webhook Gitea push event, HMAC-SHA256 in X-Gitea-Signature -> {"queued": true}
Credentials (Gitea token, webhook secret) are read from OpenBao through the
Kubernetes auth role on every use; they never land in a Secret or a log line.
Standard library only.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
LOG = logging.getLogger("wiki-index")
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.renner.uno").rstrip("/")
NOTES_REPO = os.environ.get("NOTES_REPO", "k.renner/notes")
NOTES_BRANCH = os.environ.get("NOTES_BRANCH", "master")
WIKI_DIR = os.environ.get("WIKI_DIR", "wiki").strip("/")
EMBED_URL = os.environ.get("EMBED_URL", "http://wiki-embed.ai.svc.cluster.local:8080").rstrip("/")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3")
EMBED_CHARS = int(os.environ.get("EMBED_CHARS", "1500"))
INDEX_PATH = Path(os.environ.get("INDEX_PATH", "/data/index.json"))
RESYNC_SECONDS = int(os.environ.get("RESYNC_SECONDS", "1800"))
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
BAO_ROLE = os.environ.get("BAO_ROLE", "wiki-index")
BAO_SECRET_PATH = os.environ.get("BAO_SECRET_PATH", "homelab/data/apps/ai/wiki-index")
SA_TOKEN = Path(os.environ.get("SA_TOKEN", "/var/run/secrets/kubernetes.io/serviceaccount/token"))
class ApiError(Exception):
pass
# ---------------------------------------------------------------- http helpers
def http(method: str, url: str, body=None, headers=None, timeout: float = 60.0):
data = None
hdrs = dict(headers or {})
if body is not None:
data = json.dumps(body).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
raise ApiError(f"{method} {url}: HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:300]}") from e
except urllib.error.URLError as e:
raise ApiError(f"{method} {url}: {e.reason}") from e
if "json" in ctype:
return json.loads(raw.decode("utf-8"))
return raw
# ---------------------------------------------------------------- credentials
class Credentials:
"""Gitea token and webhook secret from OpenBao, fetched per use."""
def __init__(self, static: dict | None = None):
self.static = static # tests / local runs
def fetch(self) -> dict:
if self.static is not None:
return self.static
jwt = SA_TOKEN.read_text(encoding="utf-8").strip()
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
token = login["auth"]["client_token"]
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_SECRET_PATH}", headers={"X-Vault-Token": token})
data = {k: str(v).strip() for k, v in kv["data"]["data"].items()} # `bao kv put key=@file` keeps the file's newline
for key in ("gitea_token", "webhook_secret"):
if not data.get(key):
raise ApiError(f"OpenBao {BAO_SECRET_PATH} has no key {key}")
return data
# ---------------------------------------------------------------- gitea
class Gitea:
def __init__(self, token: str, base: str = GITEA_URL, repo: str = NOTES_REPO, branch: str = NOTES_BRANCH):
self.base, self.repo, self.branch = base, repo, branch
self.headers = {"Authorization": f"token {token}"}
def api(self, path: str, **params):
q = ("?" + urllib.parse.urlencode(params)) if params else ""
return http("GET", f"{self.base}/api/v1/repos/{self.repo}{path}{q}", headers=self.headers)
def head(self) -> str:
return self.api(f"/branches/{urllib.parse.quote(self.branch, safe='')}")["commit"]["id"]
def tree(self, commit: str) -> dict[str, str]:
"""path -> blob sha for every wiki note at that commit."""
out: dict[str, str] = {}
page, per_page, seen = 1, 1000, 0
while True:
t = self.api(f"/git/trees/{commit}", recursive="true", per_page=per_page, page=page)
entries = t.get("tree") or [] # Gitea answers "tree": null past the last page
for e in entries:
p = e.get("path", "")
if e.get("type") == "blob" and p.startswith(WIKI_DIR + "/") and p.endswith(".md") and "/" not in p[len(WIKI_DIR) + 1:]:
out[p] = e["sha"]
seen += len(entries)
total = int(t.get("total_count") or 0)
if not entries or len(entries) < per_page or (total and seen >= total):
break
page += 1
if page > 50:
raise ApiError("tree paging runaway")
return out
def raw(self, path: str, ref: str) -> str:
data = http("GET", f"{self.base}/api/v1/repos/{self.repo}/raw/{urllib.parse.quote(path)}?ref={urllib.parse.quote(ref, safe='')}",
headers=self.headers)
return data.decode("utf-8") if isinstance(data, bytes) else json.dumps(data)
# ---------------------------------------------------------------- notes
def parse_frontmatter(text: str) -> tuple[dict, str]:
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*(?:\n|$)", text, re.DOTALL)
if not m:
return {}, text
fm: dict = {}
for line in m.group(1).splitlines():
mm = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if mm:
fm[mm.group(1)] = mm.group(2).strip()
return fm, text[m.end():]
def as_list(value: str) -> list[str]:
s = (value or "").strip()
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
return [p.strip().strip("'\"") for p in s.split(",") if p.strip()]
def first_paragraph(body: str) -> str:
para: list[str] = []
seen_h1 = False
for line in body.splitlines():
if line.startswith("# "):
seen_h1 = True
continue
if not seen_h1:
continue
if line.strip() == "" or line.startswith("#"):
if para:
break
continue
para.append(line.strip())
return " ".join(para)
def note_text(slug: str, text: str) -> tuple[str, str]:
"""(title, embedding text) for one note file."""
fm, body = parse_frontmatter(text)
title = (fm.get("title") or slug).strip().strip("'\"")
parts = [slug.replace("-", " "), title, " ".join(as_list(fm.get("aliases", ""))),
" ".join(as_list(fm.get("tags", ""))), first_paragraph(body)]
return title, " ".join(parts)[:EMBED_CHARS]
# ---------------------------------------------------------------- embeddings
def embed(texts: list[str], batch: int = 2) -> list[list[float]]:
out: list[list[float]] = []
for i in range(0, len(texts), batch):
resp = http("POST", f"{EMBED_URL}/v1/embeddings", {"model": EMBED_MODEL, "input": texts[i:i + batch]}, timeout=120)
rows = sorted(resp["data"], key=lambda d: d["index"])
out.extend(unit(d["embedding"]) for d in rows)
return out
def unit(v: list[float]) -> list[float]:
n = sum(x * x for x in v) ** 0.5 or 1.0
return [round(x / n, 5) for x in v]
# ---------------------------------------------------------------- index
class Index:
def __init__(self, path: Path):
self.path = path
self.lock = threading.Lock()
self.notes: dict[str, dict] = {} # slug -> {sha, title, vector}
self.commit = ""
self.synced_at = 0.0
self.syncing = False
self.last_error = ""
if path.exists():
try:
d = json.loads(path.read_text(encoding="utf-8"))
self.notes, self.commit, self.synced_at = d.get("notes", {}), d.get("commit", ""), d.get("synced_at", 0.0)
LOG.info("loaded index: %d notes at %s", len(self.notes), self.commit[:8])
except (OSError, ValueError) as e:
LOG.warning("index unreadable (%s); starting empty", e)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps({"notes": self.notes, "commit": self.commit, "synced_at": self.synced_at}), encoding="utf-8")
tmp.replace(self.path)
def sync(self, gitea: Gitea) -> dict:
"""Bring the index to the branch head; returns a summary."""
with self.lock:
self.syncing = True
try:
head = gitea.head()
if head == self.commit and self.notes:
summary = {"commit": head, "changed": 0, "removed": 0, "notes": len(self.notes), "skipped": "head unchanged"}
self.synced_at = time.time()
self.save()
return summary
tree = gitea.tree(head)
slugs = {p[len(WIKI_DIR) + 1:-3]: (p, sha) for p, sha in tree.items() if not p.endswith("/_index.md")}
todo = [s for s, (p, sha) in slugs.items() if self.notes.get(s, {}).get("sha") != sha]
removed = [s for s in list(self.notes) if s not in slugs]
LOG.info("sync %s: %d notes, %d to embed, %d removed", head[:8], len(slugs), len(todo), len(removed))
fresh: dict[str, dict] = {}
for i in range(0, len(todo), 20):
chunk = todo[i:i + 20]
texts, titles = [], {}
for s in chunk:
title, text = note_text(s, gitea.raw(slugs[s][0], head))
titles[s] = title
texts.append(text)
for s, vec in zip(chunk, embed(texts)):
fresh[s] = {"sha": slugs[s][1], "title": titles[s], "vector": vec}
with self.lock:
for s in removed:
self.notes.pop(s, None)
self.notes.update(fresh)
self.commit = head
self.synced_at = time.time()
self.last_error = ""
self.save()
return {"commit": head, "changed": len(todo), "removed": len(removed), "notes": len(self.notes)}
except Exception as e:
with self.lock:
self.last_error = str(e)[:300]
raise
finally:
with self.lock:
self.syncing = False
def rank(self, query: str, k: int = 60) -> list[dict]:
q = embed([query])[0]
with self.lock:
rows = [(s, n["title"], sum(a * b for a, b in zip(q, n["vector"]))) for s, n in self.notes.items()]
rows.sort(key=lambda r: -r[2])
return [{"slug": s, "title": t, "score": round(sc, 4)} for s, t, sc in rows[:k]]
def status(self) -> dict:
"""`ready` = an index exists and can answer /rank; a failed sync keeps the last good index."""
with self.lock:
return {"ready": bool(self.notes), "notes": len(self.notes), "commit": self.commit,
"synced_at": self.synced_at, "age_s": round(time.time() - self.synced_at) if self.synced_at else None,
"syncing": self.syncing, "last_error": self.last_error or None}
# ---------------------------------------------------------------- syncer
class Syncer(threading.Thread):
"""One worker: syncs on demand (webhook) and every RESYNC_SECONDS."""
def __init__(self, index: Index, creds: Credentials, interval: int):
super().__init__(daemon=True, name="syncer")
self.index, self.creds, self.interval = index, creds, interval
self.wake = threading.Event()
def trigger(self) -> None:
self.wake.set()
RETRY_SECONDS = 60
def run(self) -> None:
while True:
try:
data = self.creds.fetch()
summary = self.index.sync(Gitea(data["gitea_token"]))
LOG.info("sync done: %s", summary)
wait = self.interval
except Exception as e: # keep serving the last good index; retry soon, not next interval
LOG.error("sync failed: %s", e)
wait = self.RETRY_SECONDS
self.wake.wait(wait)
self.wake.clear()
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, (signature or "").strip())
# ---------------------------------------------------------------- server
def make_handler(index: Index, syncer: Syncer, creds: Credentials):
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
LOG.info("%s %s", self.address_string(), fmt % args)
def send_json(self, code: int, obj) -> None:
data = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def body(self) -> bytes:
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def do_GET(self):
if self.path == "/health": # liveness: the process serves
return self.send_json(200, index.status())
if self.path == "/ready": # readiness: an index exists
st = index.status()
return self.send_json(200 if st["ready"] else 503, st)
self.send_json(404, {"error": "not found"})
def do_POST(self):
if self.path == "/rank":
try:
req = json.loads(self.body() or b"{}")
query = str(req.get("query", "")).strip()
k = max(1, min(int(req.get("k", 60)), 500))
if not query:
return self.send_json(400, {"error": "query required"})
rows = index.rank(query, k)
return self.send_json(200, {"rows": rows, "index": index.status()})
except ApiError as e:
return self.send_json(502, {"error": str(e)})
except (ValueError, TypeError) as e:
return self.send_json(400, {"error": str(e)})
if self.path == "/webhook":
raw = self.body()
try:
secret = creds.fetch()["webhook_secret"]
except ApiError as e:
return self.send_json(503, {"error": str(e)})
if not verify_signature(secret, raw, self.headers.get("X-Gitea-Signature", "")):
return self.send_json(403, {"error": "bad signature"})
try:
event = json.loads(raw or b"{}")
except ValueError:
return self.send_json(400, {"error": "bad json"})
ref = event.get("ref", "")
if ref and ref != f"refs/heads/{NOTES_BRANCH}":
return self.send_json(200, {"queued": False, "ref": ref})
syncer.trigger()
return self.send_json(200, {"queued": True})
self.send_json(404, {"error": "not found"})
return Handler
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
index = Index(INDEX_PATH)
creds = Credentials()
syncer = Syncer(index, creds, RESYNC_SECONDS)
syncer.start()
server = ThreadingHTTPServer(("", LISTEN_PORT), make_handler(index, syncer, creds))
LOG.info("listening on :%d, repo %s@%s, embed %s", LISTEN_PORT, NOTES_REPO, NOTES_BRANCH, EMBED_URL)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,976 @@
# Paperclip Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets.
**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree.
**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints.
**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md`
## Global Constraints
- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout.
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge.
- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`.
- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label).
- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`.
- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`.
- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`.
- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`.
- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`.
- Manifest comments: one terse factual line; rationale goes in commit messages/PR.
- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed.
## Review Focus
1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6.
2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7.
3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4.
4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3.
5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5.
---
### Task 1: Upstream facts that decide two manifest values
No repo changes; produces two decisions consumed by Task 6.
**Interfaces:**
- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no).
- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`):
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"`
Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed.
- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`).
Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"`
Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields.
- [ ] **Step 3: Entrypoint privilege.**
Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"`
`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6.
- [ ] **Step 4: Anonymous health response.**
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"`
Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead.
---
### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist
**Files:**
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append)
- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml`
- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list)
**Interfaces:**
- Produces: namespace `paperclip`; ESO may reference `openbao` from it.
- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`:
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: paperclip
```
- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped):
```yaml
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: paperclip
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": rook-ceph
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- toEntities:
- host
- remote-node
- world
- toEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
```
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`.
- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted).
- [ ] **Step 5: Render check.**
Run: `kubectl kustomize kubernetes/infrastructure/network-policies`
Expected: output contains `namespace: paperclip`, exit 0.
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml`
Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`.
- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`.
---
### Task 3: OpenBao secret `apps/paperclip`
Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready.
**Interfaces:**
- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints.
- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/<name>` key. Use exactly what it returns.
- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command:
`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format).
Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them.
- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch <mount>/apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=<token>`." Wait for confirmation.
- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get <mount>/apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist.
---
### Task 4: Image automation + initial digest
**Files:**
- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml`
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml`
**Interfaces:**
- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6.
- [ ] **Step 1: Create `paperclip.yaml`:**
```yaml
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: paperclip
namespace: flux-system
spec:
image: ghcr.io/paperclipai/paperclip
interval: 1h
---
# Upstream publishes no semver tags; track the nightly tag by digest.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: paperclip
namespace: flux-system
spec:
imageRepositoryRef:
name: paperclip
digestReflectionPolicy: Always
filterTags:
pattern: '^nightly$'
policy:
alphabetical:
order: asc
```
- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`.
- [ ] **Step 3: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml`
Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error.
- [ ] **Step 4: Resolve the current nightly digest.**
The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once:
Run: `docker pull ghcr.io/paperclipai/paperclip:nightly`
Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly`
Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`.
Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly`
- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`.
---
### Task 5: Postgres (CNPG) and secrets in the app directory
**Files:**
- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml`
**Interfaces:**
- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`.
- [ ] **Step 1: `cluster.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pclp-pg
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
imageName: ghcr.io/cloudnative-pg/postgresql:17
storage:
storageClass: ceph-block
size: 10Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: pclp-pg-store
bootstrap:
initdb:
database: paperclip
owner: paperclip
postgresql:
parameters:
shared_buffers: "256MB"
max_connections: "200"
checkpoint_timeout: "15min"
max_wal_size: "2GB"
wal_compression: "zstd"
archive_timeout: "30min"
synchronous:
method: any
number: 1
dataDurability: preferred
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
```
- [ ] **Step 2: `objectstore.yaml`:**
```yaml
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: pclp-pg-store
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
```
- [ ] **Step 3: `scheduled-backup.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pclp-pg-daily
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
spec:
schedule: "0 45 2 * * *"
immediate: true
backupOwnerReference: self
cluster:
name: pclp-pg
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
```
- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
```
- [ ] **Step 5: `externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: paperclip-secrets
namespace: paperclip
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: paperclip-secrets
dataFrom:
- extract:
key: apps/paperclip
```
- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.)
---
### Task 6: Server workload, storage, ingress, network policy
**Files:**
- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml`
**Interfaces:**
- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1).
- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`.
- [ ] **Step 1: `pvc.yaml`:**
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperclip-data
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
accessModes: [ReadWriteOnce]
storageClassName: ceph-block
resources:
requests:
storage: 20Gi
```
- [ ] **Step 2: `paperclip.yaml`** — replace `<INITIAL_DIGEST>` with the Task 4 value and `<SIGNUP_DISABLED>` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperclip
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
secret.reloader.stakater.com/reload: paperclip-secrets
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperclip
template:
metadata:
labels:
app.kubernetes.io/name: paperclip
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
# Telemetry is only configurable in config.json.
- name: disable-telemetry
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
command:
- sh
- -c
- |
set -eu
f=/paperclip/instances/default/config.json
mkdir -p "$(dirname "$f")"
if [ -s "$f" ]; then
jq '.telemetry.enabled = false' "$f" > "$f.tmp"
else
printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp"
fi
mv "$f.tmp" "$f"
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /paperclip
containers:
- name: paperclip
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
ports:
- name: http
containerPort: 3100
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: pclp-pg-app
key: uri
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "3100"
- name: PAPERCLIP_DEPLOYMENT_MODE
value: authenticated
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
value: private
- name: PAPERCLIP_PUBLIC_URL
value: https://paperclip.renner.uno
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
value: "<SIGNUP_DISABLED>"
envFrom:
- secretRef:
name: paperclip-secrets
volumeMounts:
- name: data
mountPath: /paperclip
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 60
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
volumes:
- name: data
persistentVolumeClaim:
claimName: paperclip-data
---
apiVersion: v1
kind: Service
metadata:
name: paperclip
namespace: paperclip
spec:
selector:
app.kubernetes.io/name: paperclip
ports:
- name: http
port: 3100
targetPort: http
```
- [ ] **Step 3: `gate-service.yaml`:**
```yaml
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-paperclip-gate
namespace: paperclip
spec:
type: ExternalName
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
```
- [ ] **Step 4: `ingress.yaml`:**
```yaml
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperclip
namespace: paperclip
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- paperclip.renner.uno
secretName: paperclip-tls
rules:
- host: paperclip.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-paperclip-gate
port:
number: 9000
```
- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`):
```yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-ingress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: paperclip-gate
toPorts:
- ports:
- port: "3100"
protocol: TCP
---
# Covers the server and every agent it spawns (same pod).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-egress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
cnpg.io/cluster: pclp-pg
toPorts:
- ports:
- port: "5432"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
```
- [ ] **Step 6: `kustomization.yaml`:**
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- pvc.yaml
- paperclip.yaml
- gate-service.yaml
- ingress.yaml
- network-policy.yaml
```
- [ ] **Step 7: Render check.**
Run: `kubectl kustomize kubernetes/apps/paperclip`
Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `<INITIAL_DIGEST>` or `<SIGNUP_DISABLED>`.
Run: `grep -rn "<INITIAL_DIGEST>\|<SIGNUP_DISABLED>" kubernetes/apps/paperclip`
Expected: no output, exit 1.
- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`.
---
### Task 7: Authentik gate
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`)
- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`.
- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block):
```yaml
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
- id: paperclip-users-group
model: authentik_core.group
identifiers:
name: Paperclip Users
state: present
attrs:
is_superuser: false
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: paperclip-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperclip-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
internal_host_ssl_validation: false
external_host: https://paperclip.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: paperclip-gate
state: present
attrs:
name: Paperclip
slug: paperclip-gate
provider: !KeyOf paperclip-gate-provider
meta_launch_url: https://paperclip.renner.uno
meta_description: "Paperclip agent orchestration"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperclip-gate]]
group: !KeyOf paperclip-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
- model: authentik_outposts.outpost
identifiers:
name: paperclip-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf paperclip-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
```
- [ ] **Step 2: `paperclip-gate-egress.yaml`:**
```yaml
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
```
- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`.
- [ ] **Step 4: Render + dry-run.**
Run: `kubectl kustomize kubernetes/apps/authentik`
Expected: exit 0 (valid YAML incl. the blueprint ConfigMap).
Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml`
Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`.
- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`.
---
### Task 8: Flux Kustomization
**Files:**
- Create: `kubernetes/clusters/homelab/paperclip.yaml`
- [ ] **Step 1:**
```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: paperclip
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 20m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/paperclip
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
- name: infrastructure-cnpg-barman
healthCheckExprs:
- apiVersion: postgresql.cnpg.io/v1
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
```
- [ ] **Step 2: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml`
Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`.
- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`.
---
### Task 9: PR, merge by user, live verification
- [ ] **Step 1: Push and open the PR** (Task 3 must be complete).
Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip`
Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "<summary of spec decisions + link to spec + bootstrap steps>\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"`
Then wait for the user to merge. Do not merge.
- [ ] **Step 2: Reconciliation.** After merge:
Run: `flux get kustomizations paperclip`
Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke.
Run: `kubectl get cluster -n paperclip pclp-pg`
Expected: `Cluster in healthy state`, 2 ready instances.
Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip`
Expected: `1/1 Running`.
- [ ] **Step 3: Gate enforced (Review Focus 4).**
Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/`
Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`.
- [ ] **Step 4: Gate not bypassable (Review Focus 3).**
`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe.
Run: `kubectl get ciliumnetworkpolicies -n default`
Expected: `No resources found` (else pick another CNP-free namespace).
Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health`
Expected: timeout (exit 28), no HTTP response.
- [ ] **Step 5: First backup (Review Focus 5).**
Run: `kubectl get backup -n paperclip`
Expected: one `pclp-pg-daily-…` with phase `completed`.
- [ ] **Step 6: Image policy.**
Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'`
Expected: `nightly` tag with a `sha256:` digest.
- [ ] **Step 7: Hand-over to the user (browser steps, theirs):**
1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin.
2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang).
3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed.
4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI.
- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out.
@@ -0,0 +1,206 @@
# Paperclip in the homelab cluster — design
Date: 2026-09-25
Status: draft, awaiting review
## Goal
Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration
server + UI) in the homelab cluster for real use: its agents do actual work against
homelab systems. This spec covers the **platform** only. Bot identities and their
credentials are out of scope (see "Deferred").
## Decisions
| Topic | Decision |
|---|---|
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`); Paperclip stores and refreshes it. Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
### Known trade-off of B (accepted for the trial)
Upstream `runChildProcess()` spawns every local adapter with
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
the shared filesystem (including every other agent's workspace and the
`ai-local-logins` credentials) and every agent-scoped secret it can read from disk. Under B, no bot may hold
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
## Architecture
```
LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns)
│ forward-auth OK
▼
paperclip Deployment (ns paperclip, 1 replica)
├─ server :3100 (+ agent CLIs as child processes)
├─ PVC /paperclip (ceph-block)
└─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3
```
## Components
All modeled on `kubernetes/apps/paperless/` unless stated.
### Namespace and baseline policy
- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`.
- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
(copy of `paperless-ns.yaml`, renamed).
- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist.
### Flux
- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`,
`path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and
CNPG `healthCheckExprs` as `paperless.yaml`.
- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list.
### Server Deployment (`paperclip.yaml`)
- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:<digest>` with the Flux
image-automation marker for digest updates.
- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC).
- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns
`/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN,
DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`.
- `serviceAccountName: paperclip-prometheus-read` with
`automountServiceAccountToken: true`. All agents share the pod filesystem, so all
of them can read that token — it is mounted anyway because the Role behind it
grants exactly `get` on `services/proxy` for
`kube-prometheus-stack-prometheus:9090` and
`kube-prometheus-stack-alertmanager:9093` in `monitoring`, and nothing else
(`kubernetes/apps/paperclip/prometheus-read-rbac.yaml`, REN-36 then REN-95).
It was `false` until then.
Three things about that grant are worth knowing before you use or debug it:
- **It scopes the HTTP method, not the endpoint.** The apiserver maps GET→`get`,
POST→`create`, PUT→`update` on a service-proxy request, so `get` alone refuses
`POST /-/reload` and `POST /-/quit` in the authorizer. It does *not* narrow
which GET endpoints are reachable: the whole read surface of both services is,
including `/api/v1/status/config` (scrape credentials there render as
`<secret>`) and the web UI.
- **Force GET in the caller.** Prometheus accepts both GET and POST on
`/api/v1/query`; under this Role only the GET form works, so a client library
that switches to POST for long queries will 403 for a reason that has nothing
to do with the query.
- **If reads start 403ing, check the Service name against `resourceNames`
first.** `resourceNames` is matched verbatim, and
`kubernetes/apps/monitoring/helmrelease-prometheus.yaml` floats the chart at
`>=72.0.0` — a release rename breaks this path silently,
with everything still looking correct in git. The port-qualified form is the
only one granted: `svc`, `svc:9090` and `http:svc:9090` are three different
strings to the authorizer.
- Port 3100; `Service paperclip` ClusterIP :3100.
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
private-hostname guard rejects the pod IP); liveness is a TCP check, since
`/api/health` runs `SELECT 1` and a DB outage must not restart the server.
- Env:
- `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key).
- `HOST=0.0.0.0`, `PORT=3100`
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
- `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`
- `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
A follow-up flips it to `true`.
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
- `PAPERCLIP_API_URL=http://paperclip.paperclip.svc.cluster.local:3100` plus
`PAPERCLIP_ALLOWED_HOSTNAMES=paperclip.paperclip.svc.cluster.local`: upstream
otherwise hands agents the public URL, and the gate (`intercept_header_auth`)
rejects their bearer tokens. The Service name also works for sandbox pods under
option C; agent JWTs still authenticate each call.
- `envFrom: secretRef paperclip-secrets`
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
### Storage
- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME`
and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime`
dirs.
### Postgres
- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`,
`ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`,
barman-cloud plugin as WAL archiver.
- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the
existing `hetzner-s3-creds` ExternalSecret pattern.
- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`.
- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup.
- PG17 deviates from the cluster-wide PG16 convention because upstream's external
Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default
is PG18.
### Secrets
One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object
extract):
- `BETTER_AUTH_SECRET`
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
encrypted agent secrets in Postgres survive PVC loss.
### Ingress and SSO
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only).
- Authentik gate, per the paperless pattern:
- blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`:
proxy provider `paperclip-gate` (`internal_host:
http://paperclip.paperclip.svc.cluster.local:3100`, `external_host:
https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding;
- outpost `ak-outpost-paperclip-gate`;
- `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno`
outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on
`paperclip.renner.uno`);
- `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress
targets it on :9000.
- Paperclip's own email/password login stays on (no header/OIDC login upstream).
### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`)
Covers the server **and every agent** (same pod under B).
- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100.
- Egress from the app pod:
- `pclp-pg` pods :5432
- kube-dns :53
- `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their
`*.renner.uno` names
- `kube-apiserver` entity :6443
- `world` TCP :443 (Anthropic API, GitHub, npm)
- No SSH egress; git over HTTPS only. The namespace baseline
(`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows
across CNPs, so an unrestricted baseline would override this list.
- Known gap (house baseline): pods in `monitoring`/`database` and other pods in
`paperclip` can reach :3100 without the gate; Paperclip's own login still applies.
### Image automation
`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository`
(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with
`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field
available since v0.35.0). First use of digest reflection in this repo.
## Guardrails
- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`.
- Git work goes through PRs; bots get no merge rights on protected branches.
- Nothing is granted under B that we would not tolerate any agent misusing.
## Bootstrap (one-time)
1. Place `apps/paperclip` values in OpenBao (the three generated secrets).
2. Merge; Flux deploys with sign-up enabled.
3. Sign up through the gate and claim the instance as admin; then set
`PAPERCLIP_AUTH_DISABLE_SIGN_UP=true`.
4. In onboarding "Connect a model", choose Claude subscription and run the shown login
**as the node user** (the entrypoint makes exec sessions root, and root-owned 0600
credentials are unreadable to the server):
`kubectl exec -it -n paperclip deploy/paperclip -- gosu node sh -c 'export CLAUDE_CONFIG_DIR=<shown path> && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login'`
Repeat after loss of the PVC or database.
## Verification
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
completes.
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
- Direct pod access from another namespace is denied (CNP).
- A test agent (claude-local) completes a trivial task using the shared Claude connection.
- Flux ImagePolicy reports a `nightly@sha256:` digest.
## Deferred
- **Option C** (sandboxed runs) — required before any per-bot credential isolation.
- **AIR-Manager** role and all bot identities/credentials.
- Public exposure.
+315
View File
@@ -0,0 +1,315 @@
# Wiki: local web interface and agent API, as a modular .NET solution
## Goal
A web interface for the Notes wiki that runs entirely in the cluster, and an
HTTP API through which agents ask the wiki typed questions and deposit facts.
Built as one C# solution with a MudBlazor front end, and built so that every
external dependency is replaceable: where the notes live, which model embeds
them, what judges a typed question, what runs an agent, how the vector index
is stored. The first adapters are what the homelab uses today. The point of
the design is that the next one is a project and a config line, not a
rewrite.
## Where we are
- `wiki-index` (Python, ns `ai`) syncs `wiki/` from `k.renner/notes` by blob
SHA, embeds through `wiki-embed` (bge-m3 on llama-server), answers
`POST /rank`. It keeps slug, title and vector, not the text. LAN-only behind
Traefik BasicAuth; the Gitea webhook hits the ClusterIP.
- `~/Notes/bin/wiki_rank.py` and `wiki_ask.py` rank through the index, build a
document context from a local vault checkout, send it with typed questions
to TypeSafe, gate on `covered`. The wiki-synth agent composes answers from
those judgements.
- New knowledge enters as a file in `raw/` (facts with provenance, never
hypotheses), distilled nightly in CI.
- The house C# project, shelly-manager, sets the conventions: net10.0, an
`.slnx` with `src/` and `test/`, xUnit, MudBlazor 9, Blazor Web App with
interactive server rendering, minimal APIs, an Authentik proxy-header
authentication handler for humans and an API-key handler for machines, a
multi-stage Dockerfile, `dotnet test` on every push and a kaniko release
pipeline to the Gitea registry. Human-facing apps sit behind an Authentik
outpost (`<app>-gate`) with a CNP that admits only that outpost.
## Architecture: ports in the core, adapters around it
```
┌──────────────────────────────────────────┐
│ Wiki.Host (ASP.NET Core + Blazor/Mud) │ composition root,
│ /api/v1, /rank, /webhook, UI pages │ config, auth
└───────────────┬──────────────────────────┘
│ uses
┌───────────────▼──────────────────────────┐
│ Wiki.Application (use cases) │ Sync, Rank, Ask,
│ │ DepositFact, Queue-
└───────────────┬──────────────────────────┘ Question, Evaluate
│ depends only on
┌───────────────▼──────────────────────────┐
│ Wiki.Core (domain + ports) │ Note, RawSource,
│ no package references │ Report, LinkGraph,
└──────────────────────────────────────────┘ the interfaces
implemented by ▲ ▲ ▲ ▲ ▲
Adapters.Gitea Adapters.LlamaCpp Adapters.TypeSafe Adapters.FileIndex Adapters.Anthropic
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
```
Rules that keep it modular, enforced by project references and a test:
- `Wiki.Core` references nothing. It holds the domain types and the port
interfaces. Adapters reference Core only; they never reference each other
or the host. `Wiki.Host` is the only project that knows which adapters
exist.
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
runs against every adapter of that port. A new adapter is done when the
suite is green. That is what makes "swap the storage" a safe operation.
- Adapters are selected by configuration and registered by name (keyed
services), so several can be active at once. The UI's lab page and the
evaluator use that to compare, the API uses the one marked active.
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
Kubernetes.
### Ports
| port | what it abstracts | first adapter | examples of the next ones |
|---|---|---|---|
| `INoteStore` | read notes, raw sources and reports at a head; create a new document (a fact becomes a raw note in the application layer, the store only moves text) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
Two ports deserve a sentence of scope each. The judge returns judgements
with probabilities, never prose, and the UI never presents its output as an
answer, only as ranked, quoted evidence. The answer model turns that
evidence into words and is allowed nothing else: it sees the selected
paragraphs and the judgements, not the whole context, and its output is
shown next to the evidence it was given. Both are single model calls inside
the request. The service runs no agent: no tool loop, no vault edits. The
wiki-synth agent in a Claude session remains the path for artifacts and for
multi-question synthesis, and it can call `/questions` itself.
### Solution layout
```
Wiki.slnx
src/
Wiki.Core/ domain types, ports, frontmatter and link parsing
Wiki.Application/ use cases; depends on Core only
Wiki.Adapters.Gitea/ INoteStore (read at a head, create)
Wiki.Adapters.LlamaCpp/ IEmbeddingModel (OpenAI-compatible embeddings)
Wiki.Adapters.TypeSafe/ IJudge (System One client)
Wiki.Adapters.FileIndex/ IVectorIndex (memory + snapshot per model)
Wiki.Adapters.Anthropic/ IAnswerModel (Messages API, streaming)
Wiki.Host/ ASP.NET Core: API, webhook, MudBlazor UI, auth,
composition root, appsettings
test/
Wiki.Core.Tests/ parsing, link graph, chunker
Wiki.Contracts/ one suite per port, parameterised over adapters
Wiki.Application.Tests/ use cases with fake ports
Wiki.Host.Tests/ API surface, auth handlers
```
Own repository `k.renner/wiki`, same shape as shelly-manager: `Dockerfile`,
`.woodpecker/test.yml` on pull requests, `.woodpecker/release.yml` building
the image with kaniko into the Gitea registry on push to `main`, tagged
`build.<unix-ts>` plus the commit SHA so Flux image automation can follow
it. The homelab repo holds only the manifests. `claude-bot` needs write
access to the new repository.
Two things the shelly-manager history settles in advance: the TypeSafe
client is written from the documented field contract (endpoint, bearer key,
`jev-latest`, Noul/Choice/Score, 255-option cap, 32k-token state budget),
there is no prior C# client to copy; and the UI needs no login page, because
identity comes from the outpost's headers, which avoids the Blazor-circuit
sign-in constraint shelly-manager ran into.
### Configuration
Adapters are declared in `appsettings` and selected by name; secrets come
through environment variables, filled by ExternalSecret, never from the file.
```json
"Wiki": {
"NoteStore": { "Active": "gitea", "gitea": { "Repository": "k.renner/notes", "Branch": "main" } },
"Embeddings": {
"Active": "bge-m3",
"Models": {
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc:8080", "Model": "bge-m3" },
"e5-large": { "Adapter": "Ollama", "Url": "http://ollama.ai.svc:11434", "Model": "multilingual-e5-large" }
}
},
"Judges": {
"Active": "typesafe",
"Models": {
"typesafe": { "Adapter": "TypeSafe", "Url": "https://api.typesafe.ai/v1/systemone", "Model": "jev-latest" },
"jev-local": { "Adapter": "TypeSafe", "Url": "http://jev.ai.svc:8080/v1/systemone", "Model": "jev" } // illustration: same adapter, other endpoint
}
},
"Index": { "Active": "file", "file": { "Path": "/data/index" } },
"AnswerModels": {
"Active": "claude-sonnet",
"Models": {
"claude-sonnet": { "Adapter": "Anthropic", "Model": "claude-sonnet-5" },
"qwen-local": { "Adapter": "OpenAI", "Url": "http://llama-server.ai.svc:8080/v1", "Model": "qwen3.6-35b-a3b" }
}
}
}
```
Every embedding model gets its own index namespace. Switching the active
model is a config change after the new index has been built in the
background; until then both exist and can be compared. Nothing is
re-embedded in place.
## The web interface (MudBlazor)
Blazor Web App, interactive server rendering, hosted in `Wiki.Host`. Static
assets vendored into the image, no CDN. Pages:
1. **Search**: a query box, results ranked by the active index with score and
tags; for a phrased question, optionally the paragraph-level hits from the
judge with their probabilities, shown as quoted evidence.
2. **Note**: frontmatter, rendered body with `[[wikilinks]]` resolved,
backlinks, sources linking to their raw file.
3. **Browse**: by tag, by `updated`, recently changed, orphans.
4. **Answers**: a question box. The answer streams in within seconds: rank,
judge, compose, all in the request. Below it the evidence: the paragraphs
the judge selected with their notes and probabilities, and the `covered`
gate; a low gate is shown as "the wiki does not cover this" instead of an
answer. A save button writes the answer to `output/` with the wiki-synth
frontmatter (`question`, `sources`, `confidence`), so it joins the
labelled set. Underneath, the saved answers, newest first, including the
ones the wiki-synth agent wrote from a session.
5. **Maintenance**: the raw queue with ages, the last consolidation and audit
reports, index and sync status.
6. **Lab**: pick any registered embedding model and judge, run the same query
through them side by side, and run the evaluator on the labelled set to
get recall@k per configuration. This is the page for "play around with
different models".
7. **Settings**: read-only view of the active adapters and their health.
No editing of notes in the browser. The vault stays a git repository edited
by the consolidate agent and by hand.
## API v1
Reads are authenticated by the hostname's gate (humans) or API key
(machines); writes always need an API key so a fact carries its author.
| method and path | purpose |
|---|---|
| `GET /api/v1/status` | index commit and age, counts, active adapters |
| `GET /api/v1/notes?q=&tag=&limit=` | list; `q` ranks through the active index |
| `GET /api/v1/notes/{slug}` | frontmatter, body, links, backlinks, sources |
| `GET /api/v1/tags` | tags with counts |
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
| `GET /api/v1/raw` | the distill queue with ages |
| `GET /api/v1/reports` | `output/` documents by type, newest first |
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
| `POST /webhook` | Gitea push, HMAC as today |
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
`fact`, or with content under a minimum length. The raw rule is enforced by
shape: there is no field for a hypothesis. The file gets the vault's
provenance frontmatter with `captured_by` set from the API key's identity.
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
thin clients and agents no longer need a vault checkout. Its result is
judgements and quotations, never prose.
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
rank the question, build the context, run the default typed questions
(answered, where, covered), hand the selected paragraphs and judgements to
the answer model, stream the prose back. Below the `covered` threshold it
returns the gap instead of an answer. Time is the sum of the parts, about
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
call over a few thousand tokens, a few cents on Sonnet, against a quarter
dollar for a full agent run.
## Auth and network
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
outpost.
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
key stays where it is. CI reaches the ClusterIP.
- Secrets (Gitea token, webhook secret, API keys, TypeSafe key, Anthropic
key) reach the
pod as environment variables from Kubernetes Secrets filled by
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
no OpenBao access. The Python service fetched its secrets from OpenBao
directly through its own Kubernetes-auth role; that role, its policy and
the `ai` → `openbao` CNP entry are retired with it.
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
`api.anthropic.com:443`. Every
further adapter that talks to a new endpoint gets its own CNP line, which
is the one place adding an adapter touches the cluster.
## Health endpoints
Three endpoints with three different meanings, wired to the .NET health
checks with tags so that the probes and the humans see the right thing:
| endpoint | meaning | probe |
|---|---|---|
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
Adapter checks (Gitea reachable, embeddings endpoint answering, judge
endpoint answering, answer model answering) are reported in `/status` and
on the Settings page and are never part of `/ready`: a judge outage must not
take the reader down. Their failures show up as red entries, not as
restarts.
## Phases
One PR each, verified before the next. The Python `wiki-index` keeps running
until phase 3 proves parity.
0. **Skeleton.** Repository, solution, `Wiki.Core` with the ports and domain
types, `Wiki.Contracts` with the suites, `Wiki.Host` serving `/health`,
`/ready` and `/api/v1/status`. CI: test and release pipelines. Verify:
image in the registry, pipeline green, the Deployment's three probes
wired and the pod Ready.
1. **Parity with the Python service.** Gitea, LlamaCpp and FileIndex
adapters, the sync use case, `/rank` and `/webhook`. Verify: the 40-query
recall eval gives the same numbers as the Python service on the same
commit; `wiki_rank.py --probe-index` against the new service passes.
2. **Read API and UI.** Notes, tags, raw, reports; Search, Note, Browse,
Maintenance pages; `wiki-gate`. Verify: a note renders with resolved
links; an unauthenticated request is redirected to Authentik.
3. **Ask.** Chunker, TypeSafe adapter, `/api/v1/ask`, scripts as clients.
Verify: `test_wiki_ask.py` passes against the API; a wiki-synth run with
the definition unchanged. Then the Python service is retired and the
`wiki-index` hostname points at the new one.
4. **Facts.** API keys as an ExternalSecret, the deposit-fact use case, `/api/v1/facts`. Verify:
a fact posted from the workstation shows as a `claude-bot` commit and in
the next distill.
5. **Questions.** Anthropic adapter, `/api/v1/questions` with streaming,
the Answers page with save. Verify: a question answered in under ten
seconds with citations that resolve to the shown evidence; a question the
wiki does not cover returns the gap; a saved answer appears in `output/`
with the wiki-synth frontmatter and is picked up by the evaluator.
6. **Lab.** Evaluator, a second embedding model and a second answer model
registered, the Lab page. Verify: two models compared on the labelled set
from the UI.
## Not in scope
- Editing notes in the browser.
- Replacing Obsidian; the vault stays a git repository and `notes-push.path`
keeps pushing.
- Public exposure; LAN and VPN only.
- Running or triggering agents. The service makes single model calls inside
a request; it never runs a tool loop or edits the vault. Distill,
consolidate and multi-question synthesis stay Claude processes in CI or in
a session; the service shows their inputs and outputs and serves their API
calls.
@@ -0,0 +1,71 @@
# Runbook — roll back a Home Assistant image bump
Use when an automatic Home Assistant update (a
`chore(image-automation): bump ghcr.io/home-assistant/home-assistant:<new>`
commit on `master`) breaks Home Assistant. The owner approves the rollback
and its timing. Atlas writes the PR; Argus merges it.
## Why a plain `git revert` is not enough
Image automation (`kubernetes/infrastructure/image-automation/`) re-scans
the registry every hour and rewrites the tag every 30 minutes to the
highest release matching the `home-assistant` ImagePolicy. A plain revert
of the bump commit would be bumped forward again within about an hour.
So the rollback commit does two things at once: it reverts the tag **and**
drops the `# {"$imagepolicy": ...}` marker from that image line. Without
the marker the Setters strategy has nothing to rewrite, so the old tag
stays pinned. It is still one commit, and reverting that commit restores
automation.
## Before you roll back
1. Find the bump commit:
```sh
git log --oneline -5 origin/master -- kubernetes/apps/home-assistant/deployment.yaml
```
2. Check the release notes of the broken version for a **database schema**
change. Home Assistant migrates the recorder schema (CNPG cluster
`ha-pg`) on start and cannot downgrade it. An older version started on a
newer schema runs without recorder history until the schema matches
again. If the bump crossed a schema change, tell the owner before going
on: the choice is roll back without history, or restore the recorder
database from backup as a separate step.
## Roll back (one commit, one PR)
```sh
git worktree add -b atlas/ha-rollback-<old> "$WT" origin/master && cd "$WT"
git revert --no-commit <bump-sha>
# In kubernetes/apps/home-assistant/deployment.yaml, delete the trailing
# # {"$imagepolicy": "flux-system:home-assistant"}
# from the home-assistant image line. Leave every other line alone.
git diff --cached --stat # expect: 1 file, 1 line changed
git commit -m "fix(home-assistant): pin <old>, pause image automation (bump to <new> broke HA)"
git push -u origin HEAD
```
The PR description states: broken version, pinned version, symptom, schema
check result, and "revert this commit to resume automatic updates".
HA-impact review and merge follow [README.md](../README.md#2-ha-impact-review-before-merge).
On merge Flux applies the Deployment (strategy `Recreate`): Home Assistant
restarts once, about one to two minutes of downtime.
## Verify
```sh
kubectl -n home-assistant get deploy home-assistant \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="home-assistant")].image}'
kubectl -n home-assistant rollout status deploy/home-assistant
```
Read-only commands only (see REN-240 protection rule 1). One hour after
merge, confirm no new `chore(image-automation)` commit touched
`deployment.yaml`.
## Resume automatic updates
When a fixed Home Assistant release is out, revert the rollback commit. The
marker comes back, and image automation bumps to the newest release on its
next pass. That bump is itself a Home Assistant restart.
@@ -70,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.36.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -122,7 +122,7 @@ spec:
claimName: audiobookshelf-metadata
- name: books
persistentVolumeClaim:
claimName: audiobookshelf-books
claimName: audiobookshelf-books-bulk
- name: le-ca
emptyDir:
medium: Memory
+2 -2
View File
@@ -35,14 +35,14 @@ spec:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: audiobookshelf-books
name: audiobookshelf-books-bulk
namespace: audiobookshelf
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
storageClassName: ceph-filesystem-bulk
resources:
requests:
storage: 300Gi
@@ -976,6 +976,280 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# wiki-gate: the wiki service (ns ai) behind the same passkey flows as
# llama-gate. The service trusts X-Authentik-Username only from pod
# addresses, and its CNP admits only this outpost, so the gate is the
# whole access control for wiki.renner.uno.
- id: wiki-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: wiki-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://wiki.ai.svc.cluster.local:8080
internal_host_ssl_validation: false
external_host: https://wiki.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: wiki-gate
state: present
attrs:
name: Wiki
slug: wiki-gate
provider: !KeyOf wiki-gate-provider
meta_launch_url: https://wiki.renner.uno
meta_description: "Notes wiki: search, notes, answers, maintenance"
- model: authentik_outposts.outpost
identifiers:
name: wiki-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf wiki-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
- id: paperclip-users-group
model: authentik_core.group
identifiers:
name: Paperclip Users
state: present
attrs:
is_superuser: false
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: paperclip-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperclip-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
internal_host_ssl_validation: false
external_host: https://paperclip.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: paperclip-gate
state: present
attrs:
name: Paperclip
slug: paperclip-gate
provider: !KeyOf paperclip-gate-provider
meta_launch_url: https://paperclip.renner.uno
meta_description: "Paperclip agent orchestration"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperclip-gate]]
group: !KeyOf paperclip-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
- model: authentik_outposts.outpost
identifiers:
name: paperclip-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf paperclip-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# navidrome-gate: the Navidrome web UI. Navidrome auto-creates the
# X-Authentik-Username user on first login; the first user is admin.
# /rest and /share bypass the gate at the Traefik router.
- id: navidrome-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: navidrome-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://navidrome.navidrome.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://navidrome.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: navidrome-gate
state: present
attrs:
name: Navidrome
slug: navidrome-gate
provider: !KeyOf navidrome-gate-provider
meta_launch_url: https://navidrome.renner.uno
meta_description: "Music streaming (Subsonic)"
- model: authentik_outposts.outpost
identifiers:
name: navidrome-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf navidrome-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# lidarr-gate: Lidarr runs with AuthenticationMethod=External, so the gate
# is its only access control.
- id: music-librarians-group
model: authentik_core.group
identifiers:
name: Music Librarians
state: present
attrs:
is_superuser: false
# Membership is owned here; blueprint apply reverts UI edits.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: lidarr-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: lidarr-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://lidarr.navidrome.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://lidarr.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: lidarr-gate
state: present
attrs:
name: Lidarr
slug: lidarr-gate
provider: !KeyOf lidarr-gate-provider
meta_launch_url: https://lidarr.renner.uno
meta_description: "Music library gaps: discographies, Spotify artists"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, lidarr-gate]]
group: !KeyOf music-librarians-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: lidarr-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf lidarr-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
@@ -7,6 +7,7 @@ resources:
- helmrelease.yaml
- paperless-gate-ingress.yaml
- paperless-gate-egress.yaml
- paperclip-gate-egress.yaml
- vscode-gate-egress.yaml
- shelly-manager-gate-egress.yaml
- esphome-gate-egress.yaml
@@ -16,6 +17,9 @@ resources:
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- llama-gate-egress.yaml
- wiki-gate-egress.yaml
- navidrome-gate-egress.yaml
- lidarr-gate-egress.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
@@ -0,0 +1,20 @@
# Lets the lidarr-gate outpost reach Lidarr; the matching ingress rule is in
# network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: lidarr-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: lidarr-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: lidarr
toPorts:
- ports:
- port: "8686"
protocol: TCP
@@ -0,0 +1,20 @@
# Lets the navidrome-gate outpost reach Navidrome; the matching ingress rule
# is in network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: navidrome-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: navidrome-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: navidrome
toPorts:
- ports:
- port: "4533"
protocol: TCP
@@ -0,0 +1,19 @@
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
@@ -0,0 +1,21 @@
# Lets the wiki-gate outpost reach the wiki service. The authentik namespace
# baseline CNP otherwise stops any authentik pod from entering ai. Scoped to
# this outpost's pod label; the matching ingress rule is in ai-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wiki-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: wiki-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: wiki
toPorts:
- ports:
- port: "8080"
protocol: TCP
+32
View File
@@ -30,7 +30,39 @@ metadata:
# Web Service 9 = Music Assistant
# Web Service 10 = TRMNL
# Web Service 11 = Paperless (gate-level only, see its entry)
#
# CORRECTION (REN-21, 2026-09-25) to the Web Service 11 comment further down,
# which claims that an in-cluster probe of the paperless ClusterIP "needs a
# CiliumNetworkPolicy first — the monitoring probes get away without one only
# because their targets share a namespace with the prober".
#
# That is wrong, and it was wrong when written. It is stated here rather than
# fixed in place because the sentence lives inside the `config.yaml: |` block
# scalar below: any edit to it changes ConfigMap DATA, and
# `configmap.reloader.stakater.com/reload: gatus-config`
# (deployment.yaml#reloader) would then roll the gatus Deployment — a
# production restart for a comment. This header is outside `data:`, so it is
# inert; the served config is byte-identical.
#
# * When written, the paperless baseline admitted the whole monitoring
# namespace on every port, and monitoring is where blackbox-exporter runs.
# That admit is gone (REN-28); the probe is now admitted by the dedicated
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-allow-blackbox,
# scoped to blackbox-exporter on the paperless pod's :8000. Cilium unions
# the allow rules of every CNP selecting an endpoint, so nothing else is
# required.
# * The in-cluster paperless probe therefore exists:
# ../monitoring-rules/probes-blackbox.yaml#paperless-app-health, target
# http://paperless.paperless.svc.cluster.local:80/accounts/login/
# * The claim does NOT generalise to gatus itself. Gatus runs in `apps`,
# which is a different source namespace; a gatus-sourced in-cluster check
# would need its own allow, exactly as
# cnp-cross-namespace-allow-apps-gatus-to-monitored-services describes.
#
# The Web Service 11 entry below stays a reachability-only check on purpose.
# The app-health half of the pair is the blackbox probe named above.
data:
# anchor: gatus-endpoints
config.yaml: |
storage:
type: sqlite
+2 -1
View File
@@ -6,6 +6,7 @@ metadata:
labels:
app: gatus
annotations:
# anchor: reloader
# Gatus reads its config once at startup and never re-reads the file, so
# without this a ConfigMap change reconciles cleanly and then does nothing:
# git and the running checks drift apart silently, with no failed resource
@@ -46,7 +47,7 @@ spec:
type: RuntimeDefault
containers:
- name: gatus
image: twinproduction/gatus:v5.36.0 # {"$imagepolicy": "flux-system:gatus"}
image: twinproduction/gatus:v5.37.0 # {"$imagepolicy": "flux-system:gatus"}
securityContext:
allowPrivilegeEscalation: false
capabilities:
@@ -1,15 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: home-assistant-backup-password
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: home-assistant-backup-password # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/home-assistant/backup-password
@@ -95,7 +95,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.9.3 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.9.4 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -31,17 +31,70 @@ metadata:
namespace: home-assistant
spec:
instances: 2
# switchover, not CNPG's default `restart`: any change that rolls the pods
# (walStorage below did) then promotes the already-rolled replica instead of
# restarting the primary in place, so the recorder loses seconds of writes
# rather than stopDelay plus a pg_wal move plus startup (REN-176).
primaryUpdateMethod: switchover
smartShutdownTimeout: 10
stopDelay: 60
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
storageClass: ceph-block
# Sized for the migrated statistics plus 7 days of states at the current
# rate: statistics 10.8M rows + statistics_short_term 1.9M (~1.8 GB with
# indexes in Postgres) and ~18M states rows for the purge_keep_days:7 window
# (~4 GB). 20Gi leaves room for autovacuum bloat and index churn.
# ceph-block has allowVolumeExpansion: true, so this can grow in place.
size: 20Gi
# Sized for WAL headroom during an archiving stall, not for table growth.
# The data itself needs far less: migrated statistics 10.8M rows +
# statistics_short_term 1.9M (~1.8 GB with indexes in Postgres) and ~18M
# states rows for the purge_keep_days:7 window (~4 GB).
#
# 20Gi left only 6.63 GiB free, and that is the number that matters here.
# WAL archiving stalled for 10 minutes and the un-archived backlog reached
# 1.84 GiB — 28% of that free space (REN-129).
#
# That stall was first blamed on Velero repository maintenance. It is not
# the cause: REN-136 measured seven nights and the archiver's sustained
# drain (~14 segments/min, its serial ceiling) is flat whether Kopia is
# running or not, and Kopia's nearest batch drifts across 02:03-02:24Z
# while the burst stays pinned to 02:13Z. What is real is a nightly
# producer burst — 56-119 segments queued at 02:13-02:15 UTC on all seven
# nights, against a steady-state queue of 0. Postgres cannot recycle a WAL
# segment until the archiver has shipped it, so a burst that arrives while
# archiving is stalled walks the volume toward full, and a full PGDATA
# stops the recorder outright.
#
# 32Gi takes free space to ~18.6 GiB, which puts the same 1.84 GiB backlog
# at 10% of headroom rather than 28%. In REN-136's terms: at 6.36 GiB free
# one stalled night survives and two fill the volume; ~18.6 GiB carries
# about four. Approved by the owner on REN-135.
# ceph-block has allowVolumeExpansion: true, so this grows in place.
#
# Since REN-176 WAL lives on walStorage below, so the stall headroom above
# now belongs to that volume. 32Gi stays because a PVC cannot shrink.
size: 32Gi
# WAL on its own volume (ha-pg-N-wal), approved by the owner on REN-176.
#
# PERMANENT. The CNPG webhook rejects removing walStorage once set ("walStorage
# cannot be disabled once configured"), so reverting this block fails
# admission and leaves the home-assistant Kustomization stuck. Undoing it
# means a new Cluster recovered from ha-pg-store. Adding it on 1.30.1 needs
# no re-clone: the operator creates the PVC, rolls each pod, and the instance
# manager moves pg_wal onto it before Postgres starts.
#
# This does NOT keep the recorder up when WAL fills: a full WAL disk still
# stops Postgres. What it does is keep WAL writable if PGDATA fills (a purge
# that stops running), and make WAL-volume usage a direct archiver-backlog
# signal.
#
# Sized so stall survival is no worse than the shared 32Gi gives today.
# Today: ~18.6 GiB free on PGDATA / 4.66 GiB/day of WAL = ~4.0 days.
# 1.86 GiB normal pg_wal footprint (max_wal_size 2GB)
# + 18.64 GiB four days of full archiver outage at 4.66 GiB/day
# (the nightly 56-119 segment burst, REN-136, is inside that rate)
# = 20.5 GiB -> 21Gi: (21 - 1.86) / 4.66 = ~4.1 days. 18Gi would give ~3.5,
# worse than today. RBD is thin, so the ceiling costs nothing until a stall
# fills it. The webhook requires min_wal_size < max_wal_size < this size.
walStorage:
storageClass: ceph-block
size: 21Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
@@ -1,7 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- backup-password-externalsecret.yaml
- pvcs.yaml
- deployment.yaml
- service.yaml
@@ -16,6 +15,9 @@ resources:
# health broker credential: OpenBao-backed + Class-A rotated (first of the
# eight users to migrate off mosquitto-bootstrap.yaml).
- mosquitto-health-credentials-externalsecret.yaml
# open3e-int broker credential: open3e-esp32 gateway on the Vitocal internal
# CAN; OpenBao-backed + Class-A rotated.
- mosquitto-open3e-int-credentials-externalsecret.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
@@ -12,7 +12,7 @@ metadata:
#
# Listed per-Secret rather than with the blanket auto annotation, so that
# enrolling another broker user is a visible edit to this line.
secret.reloader.stakater.com/reload: mosquitto-health-credentials
secret.reloader.stakater.com/reload: mosquitto-health-credentials,mosquitto-open3e-int-credentials
spec:
replicas: 1
strategy:
@@ -78,6 +78,7 @@ spec:
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd open3e-int "$OPEN3E_INT_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd health "$HEALTH_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
@@ -124,6 +125,12 @@ spec:
user open3e
topic readwrite open3e/#
# open3e-esp32 gateway on the Vitocal internal CAN: own subtree
# under open3e/ so edge-1's listener on open3e/cmnd never sees
# its commands.
user open3e-int
topic readwrite open3e/int/#
user evcc
topic read open3e/#
topic readwrite evcc/#
@@ -187,6 +194,13 @@ spec:
secretKeyRef:
name: mosquitto-open3e-credentials
key: password
# open3e-int: OpenBao-backed + Class-A rotated (see
# mosquitto-open3e-int-credentials-externalsecret.yaml).
- name: OPEN3E_INT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-open3e-int-credentials
key: password
- name: EVCC_PASSWORD
valueFrom:
secretKeyRef:
@@ -0,0 +1,21 @@
# Projects the mosquitto `open3e-int` broker credential (open3e-esp32 gateway
# on the Vitocal internal CAN) from OpenBao into the Secret the broker
# consumes. Rotator: kubernetes/infrastructure/openbao/rotation/mosquitto-open3e-int.yaml.
#
# creationPolicy is implicitly Owner: deleting this ExternalSecret deletes the
# live Secret. Set creationPolicy: Orphan first to detach without destroying.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-open3e-int-credentials
namespace: home-assistant
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-open3e-int-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-open3e-int-credentials
+19 -8
View File
@@ -24,7 +24,7 @@ spec:
# unchanged, so Kubernetes has no reason to replace anything, and
# this image is deliberately NOT enrolled in Flux image automation
# — see kubernetes/infrastructure/image-automation/README.md, which
# lists ghcr.io/konairius/ical-proxy as having "only main, latest,
# lists gitea.renner.uno/konairius/ical-proxy as having "only main, latest,
# and main-<sha> commit builds; no release tags", so there is no
# ImagePolicy watching it. The pods therefore sat on a 21-day-old
# image while :latest moved underneath them, and the only way to
@@ -35,15 +35,26 @@ spec:
# what is RUNNING recoverable from this repo rather than "whatever
# :latest meant the last time a pod happened to restart".
#
# main-99a912b is the merge of konairius/ical-proxy#12, which adds
# the summary_match/summary_replace parameters this deployment
# needs to strip "| Abfuhrkalender - Landkreis Amberg-Sulzbach"
# from every waste collection.
# The image comes from the Gitea package registry, built by
# Woodpecker (.woodpecker.yml in konairius/ical-proxy) on every
# push to main. It is amd64-only and pulls without credentials, so
# no imagePullSecret. main-4783abe adds two hardening changes on
# top of main-db90a59: an SSRF guard (upstreams and redirect hops
# resolving to private/loopback/link-local addresses are refused;
# public redirects are still followed, up to 5) and a cap of 20
# concurrent /proxy upstream fetches (over the cap: 429 +
# Retry-After). main-73b5d97 then raises the server WriteTimeout
# from 10s to 40s (the 30s upstream fetch timeout + 10s headroom),
# so a slow-but-legitimate upstream is no longer cut off before
# /proxy can answer. main-926ead0 caps the upstream response body
# at 2 MiB (larger feeds get 413 instead of being buffered whole,
# REN-230) and logs upstream fetch failures with the host,
# answering 502 instead of 500. The summary_match/summary_replace
# parameters this deployment needs are unchanged.
#
# Bumping this is a manual, reviewed edit. The tag format is
# main-<short-sha> (docker/metadata-action type=sha with the branch
# as prefix).
image: ghcr.io/konairius/ical-proxy:main-99a912b
# main-<short-sha>, the first 7 characters of the main commit.
image: gitea.renner.uno/konairius/ical-proxy:main-926ead0
ports:
- containerPort: 8080
name: http
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.2.2 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.2.4 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+32
View File
@@ -28,6 +28,38 @@ spec:
requests:
storage: 300Gi
---
# Media root on the EC 2+1 bulk pool; thumbs mount separately below.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-library-bulk
namespace: immich
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem-bulk
resources:
requests:
storage: 300Gi
---
# Thumbnails stay replicated: read affinity serves them from the local OSD.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-thumbs
namespace: immich
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 30Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+7 -2
View File
@@ -87,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.2.2 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.2.4 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
@@ -110,6 +110,8 @@ spec:
volumeMounts:
- name: library
mountPath: /data
- name: thumbs
mountPath: /data/thumbs
- name: immich-config-file
mountPath: /config
readOnly: true
@@ -145,7 +147,10 @@ spec:
volumes:
- name: library
persistentVolumeClaim:
claimName: immich-library
claimName: immich-library-bulk
- name: thumbs
persistentVolumeClaim:
claimName: immich-thumbs
- name: immich-config-file
secret:
secretName: immich-secrets
+5 -2
View File
@@ -4,7 +4,7 @@
# the entrypoint exit after one pass instead of looping internally —
# the CronJob handles scheduling.
#
# Books land in /audiobooks (the audiobookshelf-books PVC, shared
# Books land in /audiobooks (the audiobookshelf-books-bulk PVC, shared
# with the audiobookshelf Deployment). Config + DB live on
# libation-config (RWO ceph-block, seeded once from
# libation-bootstrap secret).
@@ -107,6 +107,9 @@ spec:
- |
trap 'cp /config-internal/AccountsSettings.json /libation-state/AccountsSettings.json' EXIT
set -e
# Strip the Audible intro/outro; re-liberated books replace their file instead of gaining a " (1)" copy.
jq '.StripAudibleBrandAudio = true | .OverwriteExisting = true' /config-internal/Settings.json > /config-internal/Settings.json.tmp
mv /config-internal/Settings.json.tmp /config-internal/Settings.json
/libation/LibationCli scan
/libation/LibationCli liberate
env:
@@ -135,7 +138,7 @@ spec:
claimName: libation-config
- name: books
persistentVolumeClaim:
claimName: audiobookshelf-books
claimName: audiobookshelf-books-bulk
- name: bootstrap
secret:
secretName: libation-bootstrap
+1 -1
View File
@@ -5,7 +5,7 @@
# SOPS-encrypted Secret on first boot (sentinel file gate); after that
# Libation reads and writes it directly so token rotation persists.
#
# Books are written to the audiobookshelf-books PVC (already provisioned
# Books are written to the audiobookshelf-books-bulk PVC (already provisioned
# by the audiobookshelf seed Kustomization, RWX ceph-filesystem). We
# share the same namespace so the PVC reference is direct — k8s PVCs
# are namespace-scoped.
@@ -1,8 +1,75 @@
---
# The agent metrics Service, declared here because the chart does not render
# one under our values (REN-147).
#
# cilium's templates/cilium-agent/service.yaml is gated twice:
#
# {{- if and .Values.agent (not .Values.preflight.enabled) .Values.prometheus.enabled }}
# {{- if (or .Values.prometheus.serviceMonitor.enabled .Values.prometheus.metricsService) }}
#
# infrastructure/cilium/helmrelease.yaml sets `prometheus.enabled: true` and
# `prometheus.serviceMonitor.enabled: false`, and leaves `metricsService` at its
# default of false. First gate passes, second does not. The chart's fallback
# branch is envoy-only and needs envoy in the agent pod, but envoy runs as its
# own DaemonSet here — so no `cilium-agent` Service was rendered at all.
#
# The consequence was silent: agents exported metrics on :9962 all along, the
# ServiceMonitor below selected a Service that did not exist, and the scrape
# pool sat at 0 active / 46 dropped targets. `cilium_*` had zero series, so
# CiliumBPFMapPressureHigh and CiliumBPFMapFull in prometheusrule-cilium.yaml
# evaluated an empty vector forever and could never fire.
#
# Why a Service here and not `prometheus.metricsService: true` in the
# HelmRelease: that flag is the chart-native fix and its rendered diff is
# exactly this one object (it appears in no other template — not the
# ConfigMap, not the DaemonSet). But setting it changes the HelmRelease values,
# which makes helm-controller run `helm upgrade` on the CNI release, and that
# three-way-merges against live objects. PROPOSAL-cilium-single-owner.md
# records that `cilium-config` has carried a `kubectl-patch` field manager; if
# any of that drift is still live, the upgrade reconciles it, and the
# `configmap.reloader.stakater.com/reload: cilium-config` annotation on the
# HelmRelease then rolls every agent node by node. A Service applied by the
# apps Kustomization cannot do that. If the drift is ever proven gone, delete
# this object and set `prometheus.metricsService: true` instead.
#
# Name and selector follow authentik-outpost-servicemonitors.yaml: a distinct
# name plus `app.kubernetes.io/managed-by: homelab`, so flipping either chart
# flag later renders the chart's own `cilium-agent` Service without an
# ownership conflict and without double-scraping the same endpoints.
apiVersion: v1
kind: Service
metadata:
name: cilium-agent-metrics
namespace: kube-system
labels:
app.kubernetes.io/managed-by: homelab
app.kubernetes.io/name: cilium-agent
app.kubernetes.io/part-of: cilium
# Copied onto every series by the ServiceMonitor's targetLabels below.
# The dashboard the chart ships queries
# cilium_bpf_map_pressure{k8s_app="cilium"}, so this label is load-bearing.
k8s-app: cilium
spec:
type: ClusterIP
# Headless on purpose: agent metrics are per-node and every endpoint has to
# be scraped. A ClusterIP would hand Prometheus one random agent per scrape.
clusterIP: None
# Agent pods only. cilium-envoy carries k8s-app: cilium-envoy and exposes
# 9964, so it cannot match.
selector:
k8s-app: cilium
ports:
# Named and numbered exactly as the chart's own Service, so the
# ServiceMonitor below stays a field-for-field copy of the chart's.
- name: metrics
port: 9962
targetPort: prometheus
protocol: TCP
---
# Cilium's agent ServiceMonitor, declared here rather than by the chart.
#
# Reproduces what cilium 1.20.1 renders with prometheus.serviceMonitor.enabled,
# taken field for field off the live object so scraping is unchanged: same name,
# same namespace, same selector, same 10s interval and node relabeling.
# Reproduces what cilium renders with prometheus.serviceMonitor.enabled: same
# name, same namespace, same 10s interval and node relabeling.
#
# It lives here because the chart's template is gated on monitoring.coreos.com/v1
# existing at install time, and on an empty cluster it does not — those CRDs
@@ -22,13 +89,14 @@ metadata:
app.kubernetes.io/part-of: cilium
spec:
selector:
# Pinned to the Service above. Without managed-by this would also match a
# chart-rendered `cilium-agent` Service and scrape the same endpoints twice.
matchLabels:
app.kubernetes.io/managed-by: homelab
app.kubernetes.io/name: cilium-agent
namespaceSelector:
matchNames:
- kube-system
# The chart's Service keeps rendering (prometheus.enabled), so the target and
# its `metrics` port are there regardless of who declares the ServiceMonitor.
targetLabels:
- k8s-app
endpoints:
@@ -0,0 +1,71 @@
# Scrape the external-secrets (ESO) controller's own metrics. Until this
# existed, nothing in this cluster could see a failed ExternalSecret sync:
# `externalsecret_status_condition` held 0 series, so no rule could be written
# about a key that silently stops syncing (REN-100).
#
# This is the scrape only. The alert rule is deliberately NOT in this change —
# its `for:` window has to be tuned against real data, and that data does not
# exist until this has been live for a while. See REN-103.
#
# A PodMonitor, NOT the chart's `serviceMonitor.enabled`, and NOT a hand-written
# ServiceMonitor. Both alternatives were checked against chart 2.11.0:
#
# * A hand-written ServiceMonitor has nothing to select. `metrics.service.enabled`
# defaults to false and infrastructure/external-secrets/helmrelease.yaml
# overrides nothing, so templates/service.yaml renders no Service for the
# controller. The container port exists (`name: metrics`, containerPort 8080)
# but nothing fronts it — the same trap that forced a PodMonitor for the Flux
# controllers.
# * Setting `serviceMonitor.enabled: true` would work, but it triggers a Helm
# UPGRADE of ESO, which re-renders the Deployment and re-applies its CRDs
# (`crds: CreateReplace` in the HelmRelease). ESO is what all 54 ExternalSecret
# manifests in this repo depend on. A PodMonitor gets the same series while
# touching the release not at all, which is the trade this repo already made
# for Velero's node-agent (velero-podmonitor.yaml) and for Flux.
#
# THIS NEEDS A NETWORK POLICY CHANGE, unlike the Flux PodMonitor: the cluster is
# default-deny and external-secrets-ns.yaml admitted only intra-namespace traffic,
# host/remote-node and ICMP — no `monitoring` grant, so this scrape would have
# been dropped and the metric would have stayed absent. The matching ingress rule
# is added to infrastructure/network-policies/external-secrets-ns.yaml in the
# same pull request. Without it this file is decoration.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: external-secrets
namespace: monitoring
labels:
app.kubernetes.io/name: external-secrets
# Not required for discovery — podMonitorSelector is empty, see
# apps/monitoring/helmrelease-prometheus.yaml — but set for consistency with
# the other monitors in this directory.
release: kube-prometheus-stack
spec:
# Lives in `monitoring` rather than in `external-secrets`, matching
# external-dns-servicemonitor.yaml and the Flux PodMonitor. Every object in
# the external-secrets namespace is owned by the ESO HelmRelease; keeping this
# out of that namespace means this change cannot interact with the release.
namespaceSelector:
matchNames:
- external-secrets
selector:
matchLabels:
# The CONTROLLER only. The webhook and cert-controller carry
# `app.kubernetes.io/name: external-secrets-webhook` and
# `-cert-controller` respectively (see the chart's
# external-secrets-webhook.selectorLabels / -cert-controller.selectorLabels
# helpers), so an exact match on the unsuffixed name excludes both. That is
# intended: the `externalsecret_*` series are emitted by the controller, and
# the other two would only add idle targets.
app.kubernetes.io/name: external-secrets
app.kubernetes.io/instance: external-secrets
podMetricsEndpoints:
# Named container port, not a hardcoded 8080, so this follows
# `metrics.listen.port` if it is ever overridden in the HelmRelease.
- port: metrics
# 60s, not the chart's 30s default: `externalsecret_status_condition` is a
# slow-moving gauge over ~54 ExternalSecrets, and the alert built on it in
# REN-103 will use a multi-minute `for:` window. 30s would double the
# samples for no gain in detection time.
interval: 60s
scrapeTimeout: 10s
@@ -0,0 +1,91 @@
# Scrape the Flux controllers' own metrics. Until this existed Prometheus held
# no `gotk_*` series at all, so nothing about Flux's own behaviour was
# queryable and no rule could be written about it (REN-97).
#
# THIS IS THE SCRAPE ONLY, and the readiness alert is not merely deferred —
# it cannot be written from this endpoint. `gotk_reconcile_condition` and
# `gotk_suspend_status` are still DECLARED by fluxcd/pkg/runtime (v0.110.3,
# runtime/metrics/recorder.go), but at the tags pinned in gotk-components.yaml
# no controller calls the recorder that populates them: grepping the six
# tagged source trees for RecordReadiness / RecordCondition / RecordSuspend
# returns zero call sites, and an unpopulated GaugeVec exports no series. So
# the readiness metrics would stay empty however this file is written. Flux
# moved per-object readiness to kube-state-metrics custom-resource-state
# (`gotk_resource_info{ready=...}`); that is REN-102, and it is a prerequisite
# for the not-Ready alert rather than a follow-up to it.
#
# What this scrape does yield, and why it is still worth merging on its own:
# * `gotk_reconcile_duration_seconds` per kind/name/namespace — reconcile
# latency and, by its absence, an object that has stopped being reconciled
# at all. RecordDuration IS called, but by FIVE of the six controllers, not
# all six: measured post-merge, 170 series from kustomize (33), source (43),
# image-reflector (68), image-automation (1) and helm (25), and ZERO from
# notification-controller. That is not a gap waiting to fill in — its three
# reconcilers are registered but never walked (controller_runtime_reconcile_
# total for controller=alert/provider/receiver is 0 over 56 h of uptime with
# 2 Alerts and 1 Provider live), so the family is absent from that pod's
# /metrics entirely and five is the permanent value. DO NOT write a rule of
# the form `count(... gotk_reconcile_duration_seconds ...) < 6`, or an
# absent() per pod: it fires forever on notification-controller. Liveness for
# all six pods is `up{job="monitoring/flux-controllers"}` (below); that pod's
# reconcile health is `controller_runtime_reconcile_errors_total`. REN-127.
# * controller-runtime's own series: reconcile totals and errors per
# controller, workqueue depth and retry counts, leader election.
# * the target itself, so `up{job="monitoring/flux-controllers"}` and the
# chart's default TargetDown rule start covering the Flux control plane.
# * the scrape REN-102 needs in place anyway.
#
# A PodMonitor, NOT a ServiceMonitor, and that is forced rather than preferred:
# gotk-components.yaml creates Services for only two of the six controllers
# (source-controller, notification-controller), and both publish port 80 ->
# targetPort `http`, which is the artifact/webhook server on :9090 — not the
# metrics port. kustomize-, helm-, image-reflector- and image-automation-
# controller have no Service at all, so there is nothing for a ServiceMonitor to
# select. All six pods do carry the same two labels and the same `http-prom`
# (:8080) container port, so one PodMonitor covers the set.
#
# NO NETWORK POLICY CHANGE IS NEEDED, verified rather than assumed, and unlike
# external-secrets-podmonitor.yaml which did need one: flux-system has no
# CiliumNetworkPolicy of its own (infrastructure/network-policies/ has no
# flux-system file), and gotk-components.yaml ships an `allow-scraping`
# NetworkPolicy admitting every namespace to :8080 (`from: [namespaceSelector:
# {}]`, port 8080). So `monitoring` is already admitted, as it was for REN-21.
# Nothing in the Flux install is touched.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: flux-controllers
namespace: monitoring
labels:
app.kubernetes.io/part-of: flux
# Not required for discovery — podMonitorSelector is empty, see
# apps/monitoring/helmrelease-prometheus.yaml — but set for consistency
# with the other monitors in this directory.
release: kube-prometheus-stack
spec:
# Lives in `monitoring` with a namespaceSelector rather than in flux-system,
# matching external-dns-servicemonitor.yaml and external-secrets-podmonitor.yaml.
# Flux owns every object in flux-system via gotk-components.yaml; keeping this
# out of that namespace means this change cannot interact with the Flux install
# itself.
namespaceSelector:
matchNames:
- flux-system
selector:
matchLabels:
app.kubernetes.io/part-of: flux
app.kubernetes.io/instance: flux-system
podMetricsEndpoints:
# Matches the controllers' `http-prom` port (containerPort 8080). Present on
# all six Deployments in gotk-components.yaml; a future controller without
# it simply yields no target rather than a broken scrape config.
- port: http-prom
path: /metrics
# 60s, not the 30s used elsewhere here, matching the ESO PodMonitor: the
# useful series are slow-moving per-object gauges and histograms over a
# few hundred Flux objects, and any rule built on them (REN-102) will use
# a multi-minute `for:`. 30s would double the samples for no gain in
# detection time — and Prometheus is already near its retention cap
# (REN-107).
interval: 60s
scrapeTimeout: 10s
@@ -15,12 +15,20 @@ resources:
- prometheusrule-netbird-availability.yaml
- prometheusrule-netbird-ingress.yaml
- prometheusrule-openbao.yaml
# Prometheus's own retention. Until REN-107 `retention: 90d` and
# `retentionSize: 30GB` were both set, the size cap bound first, and the
# effective window was a 20.5-27.2 day saw-tooth with nothing reporting it.
# Fires if the size cap binds before the time retention again.
- prometheusrule-prometheus-retention.yaml
- prometheusrule-velero.yaml
- prometheusrule-woodpecker-rotation.yaml
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
# alerted if that stopped — HetznerBackupEndpointDown only probes the S3
# endpoint, which proves reachability, not that anything was written.
- prometheusrule-cnpg-backup.yaml
# Home Assistant, its recorder DB, Mosquitto and Zigbee2MQTT (REN-244). The
# only signal for them before was the generic 15m KubePodNotReady warning.
- prometheusrule-home-assistant.yaml
# DNS publication. Nothing scraped external-dns, so it deleted six LAN
# records on 2026-09-12 and stayed silent for five days.
- prometheusrule-external-dns.yaml
@@ -30,6 +38,9 @@ resources:
- prometheusrule-dns-resolution.yaml
- probes-dns.yaml
- alloy-servicemonitor.yaml
# Agent metrics. Ships the metrics Service as well as the ServiceMonitor:
# the chart renders neither under our values, so this scraped nothing and
# the two CiliumBPFMapPressure alerts could never fire. See the file.
- cilium-servicemonitor.yaml
- cnpg-podmonitors.yaml
- crowdsec-servicemonitor.yaml
@@ -48,4 +59,22 @@ resources:
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
- intel-gpu-exporter-servicemonitor.yaml
# ESO's controller metrics. Needs the `monitoring` ingress grant added to
# infrastructure/network-policies/external-secrets-ns.yaml in the same change,
# or it scrapes nothing. See the file.
- external-secrets-podmonitor.yaml
# The alert built on what that PodMonitor collects. Split from it on purpose:
# the `for:` window had to be chosen against real series, and Prometheus held
# none for this metric until the scrape landed.
- prometheusrule-external-secrets.yaml
# Flux's own control plane. The controllers were never scraped, so nothing
# about Flux's behaviour was queryable. Scrape only: per-object readiness is
# not on this endpoint at the pinned controller versions and needs
# kube-state-metrics instead. See the file.
- flux-podmonitor.yaml
# The readiness half the PodMonitor above cannot provide. Reads
# `gotk_resource_info`, which comes from the kube-state-metrics
# customResourceState block in apps/monitoring/helmrelease-prometheus.yaml —
# these two files must land and be reverted together.
- prometheusrule-flux.yaml
- probes-blackbox.yaml
@@ -88,6 +88,179 @@ spec:
# /ready answers 200 unauthenticated and is in this namespace, so the
# same intra-namespace allowance the two above rely on covers it.
- http://loki.monitoring.svc.cluster.local:3100/ready
# APP-HEALTH probes for the two gated navidrome-namespace apps. Same
# rule as the block above (monitoring-vantage-point-rule), but these
# are the first targets here that leave the monitoring namespace.
#
# Both sit entirely behind an Authentik outpost — navidrome.renner.uno
# on ak-outpost-navidrome-gate
# (../navidrome/ingress.yaml#navidrome-gate), lidarr.renner.uno on
# ak-outpost-lidarr-gate (../navidrome/lidarr.yaml#lidarr-gate) — so an
# EXTERNAL probe of either name measures the gate, not the app, and
# stays green while the app behind
# a live gate is dead. That external signal is still wanted, and REN-2
# adds it from UptimeRobot; these are the other half of the pair.
#
# NO CiliumNetworkPolicy change is needed — but NOT because
# cnp-cross-namespace-allow-apps-gatus-to-monitored-services fails to
# apply here. It applies: its Generalization section names
# blackbox-exporter by hand. The reason we are safe is that the allow
# that note demands is PRESENT AND DEDICATED — the navidrome baseline
# CNP (infrastructure/network-policies/navidrome-ns.yaml) admits
# blackbox-exporter by pod label on exactly the two listeners these
# two probes reach, :4533 and :8686. It used to admit the whole
# monitoring namespace on every port; REN-28 narrowed it, and the
# ports in that rule are the ones these probes depend on, so a probe
# added here against a THIRD listener in that namespace would need
# that rule widened first. Run that check for the next namespace you
# add; do not skip it on the grounds that the note "is about gatus".
#
# EGRESS — the half the wiki note is silent on, and the half that is
# actually load-bearing here. Cilium enforces at BOTH ends, so the
# destination ingress allow above is worthless if the prober itself
# is in egress enforcement without a matching rule. It is not: an
# endpoint only enters egress enforcement once some policy selecting
# it declares an `egress:` block, and none does for monitoring. The
# namespace has exactly two Cilium policies and both are ingress-only:
# infrastructure/network-policies/monitoring-ns.yaml
# (default-deny-ingress) and monitoring-alloy-syslog.yaml
# (allow-syslog-world-ingress). blackbox-exporter runs in that same
# namespace (../monitoring/helmrelease-blackbox-exporter.yaml#namespace).
# Re-verified at 2b71dda1 across all 635 yaml files in the repo — 73
# Cilium objects, 38 carrying an egress or egressDeny block, none of
# them in the monitoring namespace, and the single
# CiliumClusterwideNetworkPolicy is scoped to `reserved.health` so it
# cannot select a pod. 17 other namespaces here already have an egress
# block. If a routine hardening pass ever adds one to monitoring, it
# MUST list the navidrome and paperless namespaces or these three
# cross-namespace targets go red while the *.monitoring.svc ones above
# keep working — a failure mode nobody will connect back to the
# hardening commit. The cnp-cross-namespace-* notes document only the
# ingress half; that silence is what makes this the trap.
#
# BLAST RADIUS if that egress dependency is ever broken. Nothing
# user-facing breaks: these are Prometheus-side Probe targets, not
# gatus checks, so status.renner.uno is unaffected and no workload is
# restarted. What breaks is the signal, and it breaks misleadingly.
# All three targets start failing BlackboxProbeFailing
# (prometheusrule-blackbox.yaml#blackbox-probe-failing — severity
# warning, for: 10m); none of the three is in that rule's exclusion
# list. BlackboxProbesAbsent does NOT fire, because the other 17
# targets keep producing
# probe_success — so it reads as three apps going down, not as a
# policy change. WHO NOTICES: warning routes to the default `email`
# receiver, admin@renner.uno (helmrelease-prometheus.yaml alertmanager
# route, group_wait 30s, grouped by alertname), so the owner gets one
# email roughly 10-11 minutes after the policy lands. HOW FAST UNDONE:
# the REVERT recipe below is a one-line deletion; from merge it is
# live within ~11 minutes (GitRepository interval 1m0s,
# monitoring-rules Kustomization interval 10m), or immediately with
# `flux reconcile kustomization monitoring-rules --with-source`.
#
# /ping on both: unauthenticated, and already the pods' own kubelet
# probe path (../navidrome/deployment.yaml#kubelet-probes,
# ../navidrome/lidarr.yaml#kubelet-probes). For lidarr that it is
# ungated is proven independently of the gate — its reconcile script
# (../navidrome/lidarr.yaml#reconcile-script) curls /ping in a
# loop BEFORE it has read the API key out of config.xml.
#
# STATUS CODE, unproven for all three of the new targets: module
# http_2xx follows redirects and passes only on a FINAL 2xx, while
# every piece of repo evidence behind these paths establishes only
# `< 400` — a kubelet httpGet accepts 200-399, and that reconcile
# script's `curl -f` without -L exits 0 on a 302. That evidence proves the
# paths are UNGATED, not that they answer 200. Check
# probe_http_status_code on the first scrapes; a target that settles
# on a 3xx gets REVERTED, not widened to accept 3xx.
#
# REVERT — an executed recipe, not a claim. Every command below was
# actually run against master 2b71dda1 on 2026-09-25; the results are
# what is recorded here.
#
# NO `git revert` works. All four candidates conflict in THIS file at
# 2b71dda1, because the comment blocks around these targets were
# rewritten after the probes landed (#979):
#
# git revert -m 1 0a82d61f merge of #977, paperless CONFLICT
# git revert -m 1 16238d39 merge of #976, nav + lidarr CONFLICT
# git revert cb9e73bd the paperless commit CONFLICT
# git revert 2219c928 the nav + lidarr commit CONFLICT
#
# Hand-resolving any of them is the trap: the natural --theirs
# resolution of the 16238d39 conflict also drops the paperless target
# below, leaving NO app-health probes and no signal that it happened.
#
# Delete the target lines forward instead. Only the target lines are
# part of the object; the comments are inert. Both of these were run
# and verified:
#
# # all three app-health targets — blackbox-http 20 -> 17
# sed -i '/svc\.cluster\.local:80\/ping$/d;/paperless\.paperless\.svc\.cluster\.local:80\/accounts\/login\/$/d' \
# kubernetes/apps/monitoring-rules/probes-blackbox.yaml
#
# # the paperless target alone — blackbox-http 20 -> 19
# sed -i '/paperless\.paperless\.svc\.cluster\.local:80\/accounts\/login\/$/d' \
# kubernetes/apps/monitoring-rules/probes-blackbox.yaml
#
# Verified after each run: the file still parses, blackbox-http drops
# to 17 and to 19 respectively, blackbox-edge-wan (3) and
# blackbox-tcp-relays (2) are untouched, and `git diff` contains
# nothing but the deleted target lines. Then drop whichever comment
# block is left orphaned, open a PR, and merge — timing as in the
# BLAST RADIUS paragraph above.
- http://navidrome.navidrome.svc.cluster.local:80/ping
- http://lidarr.navidrome.svc.cluster.local:80/ping
# anchor: paperless-app-health
# APP-HEALTH probe for paperless. Same rule as the block above
# (monitoring-vantage-point-rule); the blind spot it closes is the one
# gatus already documents in its Web Service 11 comment
# (../gatus/configmap.yaml#gatus-endpoints) — through the
# paperless-gate outpost EVERY path, /api/ included,
# answers 302 to an unauthenticated caller, so no external probe of
# paperless.renner.uno can tell a live app from a live gate. That
# external signal is still wanted, and REN-2 adds it from UptimeRobot;
# this is the other half of the pair.
#
# That gatus comment says the in-cluster probe "needs a
# CiliumNetworkPolicy first". That sentence was WRONG when written,
# and still is, on different grounds. The probe is admitted by the
# dedicated policy
# ../../infrastructure/network-policies/paperless-ns.yaml#paperless-allow-blackbox,
# scoped to blackbox-exporter on the paperless pod's :8000 (REN-28);
# the namespace-wide `monitoring` admit this probe used to rely on is
# gone. Cilium unions the allow rules of every CNP selecting an
# endpoint (the mechanism ../paperless/network-policy.yaml#cnp-union
# relies on), so that policy is all the probe needs. The EGRESS
# paragraph in the block above applies here identically.
#
# The stale sentence is deliberately left in place in
# apps/gatus/configmap.yaml: it sits INSIDE the `config.yaml: |`
# block scalar, so editing it changes ConfigMap data and
# `configmap.reloader.stakater.com/reload`
# (../gatus/deployment.yaml#reloader) rolls the gatus Deployment. The
# correction is written at the top of
# that file instead, outside `data:`, where it costs no restart.
#
# /accounts/login/ because paperless-ngx has no unauthenticated health
# endpoint at all. It is the app's own established "the webserver
# answers" signal — the same path its startup, liveness and readiness
# probes use (../paperless/paperless.yaml#http-probes) — and it
# renders a Django view
# rather than a static file. It does NOT cover the consumer/celery
# workers: see process-liveness-vs-protocol-liveness.
#
# The cluster-local Host header is accepted only because
# PAPERLESS_ALLOWED_HOSTS is "*"
# (../paperless/paperless.yaml#allowed-hosts). Narrow that and
# Django answers 400 here and this probe goes permanently red.
#
# This is the target most likely to fail the STATUS CODE check in the
# block above — a Django login view can answer 302 — so check it
# first, and revert rather than widen if it does. Use the REVERT
# recipe in that block: `git revert -m 1 0a82d61f` conflicts here too
# at 2b71dda1, and the tested one-line deletion for this target alone
# is the second command listed there (20 -> 19 targets).
- http://paperless.paperless.svc.cluster.local:80/accounts/login/
# WAN-path probes of the public edge BY PROXY IP. The name-based
# home.renner.uno probe above resolves in-cluster (CoreDNS/k8s_gateway)
# and never leaves the LAN — the vantage-point blind spot that let the
@@ -150,3 +323,103 @@ spec:
static:
- proxy-1.renner.uno:33080
- proxy-2.renner.uno:33080
---
# anchor: negative-control-paperless
# NEGATIVE CONTROLS for the paperless ingress scoping (REN-28 PR 2a).
#
# These four targets are here TO BE REFUSED. They are not availability
# monitoring: every one of them is a workload in the `paperless` namespace that
# blackbox-exporter has no business reaching, and each must read
# `probe_success == 1` before REN-28's PR 2b and `0` after it. That flip is the
# test. Without them, "the whole-namespace admit is gone" is an assertion about
# a CNP diff rather than a measured property of the cluster, and a later
# re-widening of the grant would be silent.
#
# Why this can only be the ingress policy under test: blackbox-exporter runs in
# `monitoring`, whose baseline CNP (infrastructure/network-policies/
# monitoring-ns.yaml) is ingress-only — it declares no `egress:` at all — and
# the cluster's default-deny.yaml is scoped `namespace: apps`, which covers
# neither `monitoring` nor `paperless`. So the only thing that can turn these
# red is paperless's own ingress policy, which is exactly the variable. Nothing
# else can make the test pass or fail.
#
# WHY A SEPARATE jobName, and why these must NEVER be folded into the
# blackbox-http Probe above: after PR 2b these targets fail permanently and BY
# DESIGN. BlackboxProbeFailing and BlackboxProbesAbsent
# (prometheusrule-blackbox.yaml#blackbox-probe-failing and
# prometheusrule-blackbox.yaml#blackbox-probes-absent) are both scoped to
# job="blackbox-http", so a distinct job is outside both and stays silent — and
# the hand-maintained instance!~ exclusion regex in the first of them, whose
# fragility that file warns about, does not have to grow a fifth entry. Adding
# these to blackbox-http instead would
# page the on-call forever.
#
# The inverted alert that gives these targets teeth — CNPNegativeControlReachable
# (prometheusrule-blackbox.yaml#cnp-negative-control-reachable),
# `probe_success{job="blackbox-negative-control"} == 1` — landed with PR 2b,
# together with the policy change, not before: until then these controls were
# SUPPOSED to read 1, so the alert would have fired on the correct state.
#
# TWO WAYS A BROKEN HARNESS READS AS A PASSING TEST. The inverted alert fires
# only on `1`, so anything that forces `0` for a reason other than the policy
# disarms it silently. Both of these are load-bearing:
#
# * The IPv4 fallback. The shared tcp_connect module
# (monitoring/helmrelease-blackbox-exporter.yaml) prefers ip6 with
# ip_protocol_fallback: true. All four Services are SingleStack IPv4, so
# ip6 never resolves and every `1` these controls ever read came through
# the fallback (probe_ip_protocol == 4). Set the fallback to false and all
# four drop to `0` with the policy wide open. Do not change the module for
# these targets — blackbox-tcp-relays pins ip6-first on purpose; give this
# job its own module instead.
# * scrapeTimeout: 10s below. Cilium drops the SYN, so a refused connect runs
# to the module's own 5s timeout. 5s < 10s is what keeps the SCRAPE
# succeeding, so `up` stays 1 and probe_success reads 0 instead of going
# absent. Do not trim scrapeTimeout toward 5s: a scrape that times out
# leaves no probe_success series for the inverted alert to read, and sets
# `up` to 0, which the chart's job-agnostic TargetDown (defaultRules are on
# in monitoring/helmrelease-prometheus.yaml) can page on.
#
# A bare TCP connect is the right probe: it completes or it times out, with no
# application-layer ambiguity about whether a 403 meant the policy or the app.
# Known, accepted side effect: the pprl-pg-rw connect opens and closes without a
# startup packet, so Postgres logs "incomplete startup packet" at LOG level once
# a minute until PR 2b lands and the connect stops arriving.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: blackbox-negative-control-paperless
namespace: monitoring
labels:
# Load-bearing, as noted at the top of this file: probeSelector matches on
# it. Without it this Probe is never discovered and the control reads
# `absent`, not `1` — which would look like a passing negative test.
release: kube-prometheus-stack
spec:
jobName: blackbox-negative-control
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: tcp_connect
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
# Proves BOTH halves of the pprl-pg scoping PR 2b will introduce: the
# source (blackbox is not Prometheus, and the pprl-pg-allow-prometheus
# rule 2b adds admits only Prometheus) and the port (5432 is not the
# exporter's 9187). Both that rule and paperless-allow-blackbox
# (../../infrastructure/network-policies/paperless-ns.yaml#pprl-pg-allow-prometheus,
# ...#paperless-allow-blackbox) now exist, which is why this target
# must read 0. Service is CNPG-generated from Cluster `pprl-pg`
# (../paperless/cluster.yaml#pprl-pg) and is the name paperless itself
# dials (../paperless/paperless.yaml#db-host).
- pprl-pg-rw.paperless.svc.cluster.local:5432
# Three witnesses that the whole-namespace `monitoring` admit is gone
# for workloads no new rule selects. Ports from the Services themselves:
# ../paperless/redis.yaml#service-port,
# ../paperless/tika.yaml#service-port,
# ../paperless/gotenberg.yaml#service-port.
- redis.paperless.svc.cluster.local:6379
- tika.paperless.svc.cluster.local:9998
- gotenberg.paperless.svc.cluster.local:3000
@@ -31,6 +31,7 @@ spec:
# target means. A target whose loss deserves a critical page gets its
# own rule — and lands in the list above. That is what keeps the
# exclusion list from being arbitrary.
# anchor: blackbox-probe-failing
- alert: BlackboxProbeFailing
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
for: 10m
@@ -46,6 +47,7 @@ spec:
# The check that the checks are running. Every rule above reads
# probe_success; if the series stop being produced at all, none of them
# can fire and the silence looks exactly like health.
# anchor: blackbox-probes-absent
- alert: BlackboxProbesAbsent
expr: absent(probe_success{job="blackbox-http"})
for: 15m
@@ -57,3 +59,42 @@ spec:
they are all blind until it returns.
labels:
severity: critical
# The inverted alert for the paperless negative controls (REN-28 §5.6).
# A separate group so nothing above can grow a matcher for it by accident.
- name: blackbox-negative-control.rules
rules:
# The four targets in probes-blackbox.yaml#negative-control-paperless are
# paperless-namespace workloads that blackbox-exporter must NOT reach
# since REN-28 PR 2b scoped the paperless ingress to per-workload
# policies. `0` is healthy here and `1` is the failure: a control that
# connects means the grant was re-widened — a namespace-wide
# `monitoring` admit back on the paperless baseline, or a per-workload
# policy whose selector grew — and blackbox, a pod with no business
# there, can open TCP into the namespace whose paperless pod trusts
# X-Authentik-Username from anything that connects.
#
# Scoped to job="blackbox-negative-control" ONLY. Never blackbox-http,
# whose targets are meant to read 1.
#
# This rule reads 0 as silence, so anything that forces 0 for another
# reason disarms it without a sound. The two known ways are commented
# on the Probe: the IPv4 fallback of the shared tcp_connect module, and
# scrapeTimeout staying above the module's 5s timeout.
#
# for: 15m rides out a short policy churn during a Cilium restart or a
# CNP re-apply; a real re-widening is permanent and fires regardless.
# anchor: cnp-negative-control-reachable
- alert: CNPNegativeControlReachable
expr: probe_success{job="blackbox-negative-control"} == 1
for: 15m
annotations:
summary: "Negative control {{ $labels.instance }} is reachable from blackbox-exporter"
description: >-
blackbox-exporter in `monitoring` completed a TCP connect to
{{ $labels.instance }}, which the paperless ingress policy must
refuse (REN-28). The paperless ingress grant has been re-widened.
Check infrastructure/network-policies/paperless-ns.yaml for a
namespace-wide `monitoring` admit on default-deny-ingress, or a
per-workload policy whose selector now matches this workload.
labels:
severity: warning
@@ -52,19 +52,123 @@ spec:
- alert: CephPoolLostRedundancy
# Names the pool, which POOL_NO_REDUNDANCY does not.
#
# There is no pool-size metric, but bytes_used/stored IS the
# replication factor. The >1e6 floor drops pools too small to measure
# meaningfully, empty ones included.
# There is no pool-size metric, but for a REPLICATED pool
# bytes_used/stored IS the replication factor. The >1e6 floor drops
# pools too small to measure meaningfully, empty ones included.
#
# No pool is exempt.
# ERASURE-CODED POOLS ARE EXCLUDED, AND MUST STAY EXCLUDED (REN-88).
#
# That ratio is not a replication factor for an EC pool, it is the
# coding overhead: k+m shards over k data shards. For the two ec:2+1
# pools here that is 3/2 = exactly 1.5 — sitting precisely on this
# rule's own threshold, on the wrong side of it half the time.
# Measured on `cephfs-bulk` (pool 15): used 480042086400 / stored
# 320028049408 = 1.5000000384, a margin of 12 KiB in 480 GB, so which
# way the comparison lands is decided by the last bits of a float
# division. It paged critical for 199 minutes on 2026-09-25 (12:37 to
# 15:56 UTC, two emails) with the cluster HEALTH_OK, zero degraded
# PGs and every OSD up, then cleared on its own. 50 of the 210
# samples in the preceding 30h were below 1.5; only that run held the
# 30m `for`. `migration.rgw.buckets.data` (pool 12) is the same ec:2+1
# shape and was one drift away from doing the same thing.
#
# The premise fails for EC too, not just the arithmetic: the old
# description claimed a single OSD loss destroys data, but 2+1 carries
# a parity shard and survives one OSD loss, same as replica-2.
#
# Do NOT "fix" this by nudging the threshold to 1.4 or 1.49. That
# keeps a knife-edge, just a quieter one, and it also narrows the real
# replica-2 -> replica-1 signal this rule exists to catch (1.0).
#
# EC pools keep full redundancy coverage without this rule: a real
# loss raises POOL_NO_REDUNDANCY, which is a live series in
# `ceph_health_detail` and is deliberately in scope for
# CephHealthWarn above. That check reads Ceph's own verdict instead of
# inferring one from a byte ratio, which is the right instrument here
# — an EC pool that loses a shard does not change its stored/used
# ratio at all, because Ceph does not delete the surviving shards.
# So there is no gap to reopen, and no second EC-specific threshold
# worth inventing.
#
# `on(pool_id)` is deliberately the whole matcher. ceph_pool_metadata
# carries labels ceph_pool_bytes_used does not (name, type,
# description, application, compression_mode), so default all-label
# matching would never join. Adding `cluster` would be defensive
# against a second Ceph cluster that does not exist, at the cost of
# the rule silently matching nothing if that label is ever dropped —
# an alert that fails open invisibly is the worse trade.
expr: |
ceph_pool_bytes_used / ceph_pool_stored < 1.5 and ceph_pool_stored > 1e6
ceph_pool_bytes_used / ceph_pool_stored < 1.5
and on(pool_id) ceph_pool_metadata{type="replicated"} == 1
and ceph_pool_stored > 1e6
for: 30m
labels:
severity: critical
annotations:
summary: "Ceph pool {{ $labels.pool_id }} is running unreplicated"
description: "Pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
summary: "Ceph replicated pool {{ $labels.pool_id }} is running unreplicated"
description: "Replicated pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning it is down to a single copy and one OSD loss destroys data. No replicated pool is meant to be unreplicated — it also blocks OSD maintenance for as long as it lasts, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Erasure-coded pools are out of scope for this rule and are covered by POOL_NO_REDUNDANCY via CephHealthWarn (REN-88). Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- alert: CephPoolLostRedundancyBlind
# Canary for the rule directly above. Keep the two adjacent.
#
# CephPoolLostRedundancy is `critical` and it fails CLOSED and
# SILENTLY. Its expression joins three metrics on one label and
# filters on one specific label VALUE, and if any of those stops
# matching the expression returns nothing, which is indistinguishable
# from "every pool is fine". Health stays ok, no email is sent, and
# nothing anywhere reports that a critical storage alert has gone
# dark. Measured at REN-88 review time: `type` carried exactly two
# values, `replicated` and `erasure`, across 15 series — so the
# dependency is real, and a Rook or Ceph upgrade that renames the
# label, renames the value, or drops it in a relabel rule is enough.
#
# This expression is CephPoolLostRedundancy's with both thresholds
# removed, wrapped in absent(): "can that rule produce any series at
# all". It goes to 1 if either metric disappears, if `pool_id` is
# dropped from either side of the join, if the `type` label is
# renamed, or if `replicated` stops being its value.
#
# The one deliberate difference is that `ceph_pool_stored > 1e6` is
# NOT mirrored here. That floor is about data volume, not structure —
# replicated pools that are all legitimately tiny is a quiet state,
# not a broken dependency, and copying it in would make this fire for
# a benign reason.
#
# ONE CANARY PER FRAGILE JOIN, NOT ONE PER RULE GROUP. Judged on
# drift: a group-level canary has to enumerate every metric the group
# reads, and adding a rule to the group does not update it. Six months
# on it silently under-covers while LOOKING comprehensive, which is the
# same failure shape as a discovery step that passes having checked
# nothing. This one's scope is exactly one rule sitting six lines
# above it, so "is it still true" is answerable by reading the two
# blocks together. It can still drift — someone edits the expr above
# and not this — but that drift shows up in the same diff hunk, and
# when it does bite it FIRES rather than going quiet. For a guard,
# loud and wrong beats silent and wrong.
#
# 1h, not the 30m above. The exporter moved through 17 distinct mgr
# instances in 30d, and every one of those failovers makes all ceph_*
# metrics absent for as long as it takes the new mgr to be scraped;
# anything under ~15m would page on routine failover, and noise is
# what REN-88 was about. A long `for` costs nothing in detection value
# because the failure this catches is a schema change — permanent, not
# transient — and a blindness that self-heals inside an hour is not
# worth an email. Transient exporter loss is already covered by the
# chart's own TargetDown.
#
# `warning`, not `critical`: an alert stopped being able to fire.
# Storage is not broken.
expr: |
absent(
ceph_pool_bytes_used / ceph_pool_stored
and on(pool_id) ceph_pool_metadata{type="replicated"} == 1
)
for: 1h
labels:
severity: warning
annotations:
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
- alert: CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
@@ -95,3 +199,116 @@ spec:
annotations:
summary: "{{ $labels.instance }} /var below 30% free — Ceph mon will warn"
description: "/var on {{ $labels.instance }} is under 30% free, which is Ceph's mon_data_avail_warn threshold, so the mon on this node will report MON_DISK_LOW. Find the actual consumer with `talosctl -n <node> usage /var/lib --depth 1` and then `/var/lib/containerd --depth 1` — note that overlayfs snapshots belong to running containers and are not reclaimable by image GC."
- alert: CephOSDNearfullApproaching
# Warns BEFORE Ceph's own nearfull check, which is the incident and
# not a warning. mon_osd_nearfull_ratio is 0.85 and is not
# overridden here.
#
# There is no rebalance to hide behind: failureDomain is host with
# size 3 on exactly three OSDs, so every OSD holds a copy of every
# replicated object and all three cross 0.85 within seconds of each
# other. Measured 2026-09-26: 81.86 / 81.87 / 81.83%.
#
# 0.75 is 0.85 minus the largest bulk-tier burst on record. Measured
# at 30s resolution over the 14d to 2026-09-26: the fullest OSD went
# 71.63% -> 81.87% between 05:18 and 05:43Z (+10.24 pp, ~275 GiB raw)
# and reclaimed within the hour. At a SUSTAINED 75%, one more burst of
# that size reaches nearfull, which is the point at which the level
# itself is the finding.
#
# `for: 30m` is the shortest wait that outlives a self-reclaiming
# burst. That same burst held >= 75% for 19.0 min, so 30m clears it by
# 11 min; a permanent step holds it indefinitely. Do NOT shorten this
# to 15m — the 09-26 burst then pages, with the cluster reclaiming on
# its own while someone reads the mail.
#
# Nor should it be lengthened to 1h. The fastest observed SUSTAINED
# climb was +11.44 pp in one hour (2026-09-25 08:02-09:02Z, a real
# ingest that never reclaimed). From 75% that reaches 0.85 in ~53 min,
# so a 1h pending window would fire after nearfull, not before it.
# 30m fires at ~80.7% in that worst case — about 22 min of lead — and
# at 2.24 pp/h (the worst 6h-sustained rate) it fires at ~76.1%, about
# 4h of lead. The 30m/1h choice is the whole trade: a burst reclaims
# in ~20 min and a step does not, so ~30m is the earliest honest
# verdict. REN-162 is measuring the burst envelope; if a burst is ever
# seen holding >= 75% for more than ~25 min, this `for` moves out, not
# the threshold.
#
# Replay over the 14d to 2026-09-26 08:00Z: the worst 30m-sustained
# floor on any OSD was 71.632% (09-26 04:31Z), so this rule fires ZERO
# times on the observed normal shape, including the 09-26 excursion to
# 81.87% and the 09-25 step from 59% to 71%. Margin 3.37 pp.
#
# `max by (ceph_daemon)` is not cosmetic. rook-ceph-exporter pods are
# replaced often — 32 distinct series for three OSDs in 14 days — and
# the raw expression carries `pod` and `instance`, so every restart
# would change the alert identity and reset a pending window.
expr: |
max by (ceph_daemon) (
ceph_osd_stat_bytes_used / ceph_osd_stat_bytes
) >= 0.75
for: 30m
labels:
severity: warning
annotations:
summary: "Ceph {{ $labels.ceph_daemon }} sustained above 75% — one bulk burst from nearfull"
description: "{{ $labels.ceph_daemon }} has been at or above 75% utilisation for 30 minutes ({{ $value | humanizePercentage }} now). That is 0.85 nearfull minus the largest bulk-tier burst measured here (+10.24 pp in 25 minutes), so a single repeat of that burst trips mon_osd_nearfull_ratio — and with failureDomain host, size 3 and three OSDs, it trips on all three at once with no rebalance available. This is sustained, not a spike: a burst clears in about 20 minutes and never holds this for 30. Find what grew with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df` and `ceph osd df tree`; the bulk tier (cephfs-bulk, pool 15) is the usual mover."
- alert: CephReplicapoolHeadroomLow
# The `replicapool` counterpart to the OSD rule above, in the units a
# reader can act on: GiB that still fit.
#
# These two are NOT independent signals and must not be read as
# corroboration. `ceph_pool_max_avail` for a size-3 pool on three
# equal OSDs is exactly (full_ratio - fullest_OSD_utilisation) x
# osd_size; verified against live data, the identity holds at 0.95 to
# five decimals. So this rule is the OSD series rescaled — a second
# TIER, deliberately set to trip after the OSD rule, not a second
# opinion. Ceph's own nearfull line of 0.85 sits at 89.4 GiB here.
#
# 160 GiB is what must still fit, measured:
# 89 GiB max_avail at the 0.85 nearfull line, i.e. the part of
# max_avail that is not ours to spend
# 38 GiB ha-pg's unconsumed allocation after the approved 20Gi ->
# 32Gi expansion: 2 instances x (31.33 GiB provisioned -
# 12.4 GiB used) as of 2026-09-26
# 35 GiB replicapool churn allowance — the worst observed single
# day (+14.3 GiB, 09-23) plus eleven days at the 14d mean
# of 1.9 GiB/day
# = 162 GiB, rounded DOWN to 160 so the rounding costs quiet, not
# headroom.
#
# Selecting on the pool NAME through ceph_pool_metadata, not on a
# hardcoded pool_id: ids are assignable and replicapool is only id 1
# by history. `on(pool_id)` is the whole matcher for the same reason
# given on CephPoolLostRedundancy above — ceph_pool_metadata carries
# labels ceph_pool_max_avail does not, so default matching never
# joins.
#
# `max by (pool_id)` twice over: it collapses the churning mgr `pod`
# and `instance` labels (8 series in 14 days), and during an mgr
# failover it takes the live value rather than a stale low one.
#
# `for: 30m` also covers a real exporter artefact: ceph_pool_max_avail
# reads exactly 0 for a single 30s sample after an mgr restart, twice
# in the 14d window (e.g. 09-23 12:27Z), on every mgr series at once.
# A `for` shorter than a few minutes would page on that alone. Note
# the glitch is one-directional, so it cannot cancel a genuine breach
# — 0 is still below the floor.
#
# Replay over the 14d to 2026-09-26 08:00Z: the lowest 30m-sustained
# ceiling was 208.97 GiB (09-26 04:31Z), so ZERO fires on the observed
# normal shape. The 09-26 burst took max_avail to 117.4 GiB for about
# 10 minutes and did not come close to holding it.
expr: |
max by (pool_id) (
ceph_pool_max_avail
and on(pool_id) ceph_pool_metadata{name="replicapool"}
) < 160 * 1024^3
for: 30m
labels:
severity: warning
annotations:
summary: "replicapool usable headroom below 160 GiB"
description: "replicapool has had under 160 GiB usable for 30 minutes ({{ $value | humanize1024 }}B now). 160 GiB is the 89 GiB that max_avail still holds above Ceph's 0.85 nearfull line, plus the 38 GiB ha-pg has not yet consumed of its 32Gi expansion, plus 35 GiB of observed replicapool churn — below this, what is already committed no longer fits before nearfull. This is the same series as CephOSDNearfullApproaching rescaled (max_avail = (0.95 - fullest OSD) x OSD size), so it is a second tier and not independent confirmation. Check `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df`; growth here is usually the bulk tier on the shared OSDs, not replicapool itself."
@@ -34,9 +34,20 @@ spec:
# query parameter, and an exact match would break silently if the
# instance label ever encodes it differently.
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
# Two consecutive failed probes: enough to skip a single lost probe
# through Traefik, not enough to sit on a sealed store.
for: 1m
# Five minutes of continuously failing probes — about ten consecutive
# samples, since the probe and the rule evaluation both run at 30s
# (probes-blackbox.yaml, helmrelease-prometheus.yaml). Matches
# WoodpeckerDown above.
#
# This was 1m, the tightest probe window in the cluster by 5x, and it
# went pending on a single 5s HTTPS timeout with OpenBao healthy
# (REN-131). Isolated blips are ambient on this probe fleet at roughly
# one per two hours, so a window that clears on three samples pages on
# jitter. The widening costs nothing real: per the description below
# nothing breaks the moment OpenBao goes away, and OpenBaoQuorumLost
# (prometheusrule-openbao.yaml, for: 2m) still covers the fast
# failure, which is quorum loss rather than a lost probe.
for: 5m
labels:
severity: critical
annotations:
@@ -13,7 +13,7 @@ spec:
rules:
# The Postgres clusters back up to Hetzner. HetznerBackupEndpointDown
# probes that S3 endpoint, which proves the bucket is reachable and
# nothing more — these two rules check that something is actually being
# nothing more — these rules check that something is actually being
# written to it.
- alert: CNPGWalArchiveBacklog
@@ -23,22 +23,79 @@ spec:
# that moment, and pg_wal keeps growing until it fills the PVC and
# takes Postgres down.
#
# This is the load-bearing alert of the two. It reads the
# This is the load-bearing alert of the group. It reads the
# instance-level collector directly, so unlike the backup-age metric
# discussed below it does not depend on Cluster status being
# populated.
#
# Threshold: >5 segments sustained for 30m. Brief non-zero values are
# normal under write bursts — a segment is 16 MiB, so 5 is roughly
# 80 MiB of un-archived WAL, well below anything that threatens the
# volume but far above idle noise.
expr: cnpg_collector_pg_wal_archive_status{value="ready"} > 5
for: 30m
# Threshold: >5 segments sustained for 20m. A segment is 16 MiB, so 5
# is roughly 80 MiB of un-archived WAL — well below anything that
# threatens the volume, but far above idle noise.
#
# WHAT THIS RULE KEYS ON: persistence, not depth. The failure that
# costs something is an archiver that has *stopped*, and a stopped
# archiver can sit at 60 segments indefinitely without ever reaching
# some larger number. A depth threshold tuned above the nightly
# envelope below would miss exactly that case, and would need raising
# again every time the recorder grows. So there is one rule here, and
# the tuning knob is `for` (REN-152).
#
# THE NIGHTLY BURST, which this rule must stay silent through.
# ha-pg-1 queues WAL every night on the same clock:
#
# flat 0 until 02:12Z; fill from 02:13Z; peak 02:14-02:15Z;
# drained by 02:26Z at the worst. 02:12Z is 04:12 local, which is
# when Home Assistant's recorder purge runs.
#
# It is a write burst, not IO contention. Drain is a flat
# 13-15 segments/min every night — including nights when Kopia's
# repository-maintenance batch was saturating the RBD devices, and
# nights when it did not start until after the burst had already
# drained. That batch drifts across 02:03-02:24Z while the peak stays
# pinned to 02:15Z, and a drifting cause cannot produce an effect
# fixed to the clock. What varies is the fill (19-40 segments/min),
# so the height of the backlog is set by how much WAL is produced
# against an archiver with a flat ~14 segments/min ceiling.
#
# WHY 20m. Measured against Prometheus' full 27-night retention
# (2026-08-30..2026-09-26), at the real 30s scrape resolution, across
# all 12 CNPG instances. Only ha-pg-1 ever exceeds 5 at all; every
# other instance peaks at 2. Longest contiguous excursion above 5 on
# any night: 13.0 minutes, on 2026-09-11, at a peak of 141 segments.
# Firing evaluations over those 27 nights: 0 at 20m, and also 0 at
# 15m — but 15m leaves only 2 minutes of margin against the worst
# night, and the nightly peak climbed from 116 to 141 inside the
# measured month as the recorder grew. 20m leaves 7 minutes and does
# not have to be re-derived every time the database gets bigger.
#
# The 5 minutes 20m costs in detection latency buy nothing back: a
# real stall lasts hours, and at 32Gi (REN-141) the volume holds
# roughly four nights of stalled WAL before free space is in danger.
#
# `for` was 30m before REN-129. Outside the nightly windows the
# metric is a hard 0 on all 12 instances, so there was never a noise
# floor that needed 30m to wait out — the extra 10m only delayed the
# page.
#
# WHEN IT FIRES, the fix is drain rate or headroom, never a higher
# threshold here. ObjectStore `wal.maxParallel`
# (kubernetes/apps/home-assistant/ha-pg-objectstore.yaml) is unset,
# which the barman-cloud plugin reads as one WAL segment at a time;
# that is where the ~14 segments/min ceiling comes from. Headroom is
# `spec.storage.size` in ha-pg-cluster.yaml. Both are REN-135.
#
# `max by (namespace, pod)` is not cosmetic. The raw series carries
# `role`, which flips on failover; a changed label is a new series,
# and a new series restarts the `for` timer. Aggregating `role` away
# means a backlog that persists across a failover still pages 20m
# after the backlog started rather than 20m after the failover.
expr: max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{value="ready"}) > 5
for: 20m
labels:
severity: warning
annotations:
summary: "CNPG WAL archiving is falling behind on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "{{ $value }} WAL segments have been waiting to archive for over 30 minutes. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
description: "{{ $value }} WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
- alert: CNPGBackupFailed
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
@@ -0,0 +1,227 @@
# Alert when an ExternalSecret stops syncing.
#
# This is the second half of REN-103. The first half (#994) added the PodMonitor
# and the `monitoring` ingress grant that make the series exist at all; before
# that, `externalsecret_status_condition` held 0 series and a key that silently
# stopped refreshing was invisible to Prometheus, to Alertmanager and to anyone
# looking at a dashboard. The alert is split out because its `for:` window had to
# be chosen against real data rather than guessed.
#
# `exported_namespace`, NOT `namespace`. The metric carries its own `namespace`
# label (the ExternalSecret's), and the PodMonitor attaches a target label of the
# same name (the controller pod's, always `external-secrets`). Prometheus resolves
# that collision by renaming the metric's label to `exported_namespace`, so on
# every one of the ~105 live series `namespace="external-secrets"`. Grouping by
# `namespace` would therefore still fire correctly and still be useless: every
# failing ExternalSecret in the cluster would be reported as living in
# `external-secrets`. ESO's own shipped Grafana dashboard does group by
# `namespace` — that is right for the chart's ServiceMonitor, which scrapes ESO
# through its own Service and injects no conflicting target label, and wrong here.
# This is a general trap for the PodMonitor-instead-of-ServiceMonitor trade this
# repo keeps making: any metric carrying its own `namespace`, `pod` or `container`
# label gets it shadowed.
#
# `honorLabels: true` on the PodMonitor endpoint would keep the clean label name
# instead, and is deliberately not used: it drops the target labels, so nothing
# records which controller replica reported, and it would make this the only
# monitor in the directory doing so.
#
# A consequence worth knowing rather than fixing: aggregating by
# `exported_namespace` leaves these alerts with no `namespace` label at all, and
# Alertmanager's route groups on ['alertname', 'cluster', 'namespace']. So every
# failing ExternalSecret arrives in ONE email listing all of them, each named by
# its own annotation, instead of one email per namespace. For a fleet this size
# that is the better shape.
#
# The obvious worry about that shape — that a SECOND ExternalSecret failing while
# the first alert is already firing gets swallowed by the open group — does not
# hold, and the setting that decides it is `group_interval: 5m` on the root route
# in apps/monitoring/helmrelease-prometheus.yaml. `group_interval` governs
# notification of a group whose alert SET has changed, which is a different thing
# from `repeat_interval: 12h`, which governs re-sending an unchanged group. A
# second failure therefore produces a fresh email naming both within 5 minutes.
# This rule routes to the root `email` receiver: it is `severity: warning` and
# carries no `source = "flux"`, so it matches none of the four child routes.
#
# The inhibit rules are also checked and do not touch these alerts. All three key
# on `equal: ['namespace', 'alertname']` (or `['namespace']`), and no critical or
# warning alert in the cluster shares this `alertname`. In particular
# OpenBaoQuorumLost does NOT inhibit this rule — different alertname — so an
# OpenBao outage can surface both. That is the intended order rather than a
# problem: OpenBaoQuorumLost is critical at 2m and arrives first, well before
# this rule's 15m.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: external-secrets
namespace: monitoring
labels:
# kube-prometheus-stack's ruleSelector matches on this label; without it the
# rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: external-secrets.rules
rules:
# Measured against the live Prometheus before this rule was proposed for
# merge, through the apiserver services/proxy read path:
#
# count(externalsecret_status_condition) = 105
# count(count by (exported_namespace,name) (...)) = 53
# count(count by (exported_namespace,name) (...{condition="Ready"})) = 52
# count(count by (exported_namespace) (...)) = 20
# count(...{condition="Ready",status="False"}) = 52
# the ExternalSecretSyncFailing expression below = EMPTY
#
# So 105 = 52 ExternalSecrets x 2 Ready polarities + 1 Deleted tombstone,
# across 20 namespaces, and THIS RULE ARRIVES SILENT: it matches nothing
# in the cluster as it stands, which is what makes it safe to load. The
# 52 on the line below is a count, not an estimate.
#
# `== 1` IS LOAD-BEARING, not defensive. ESO writes BOTH polarities of the
# Ready condition on every reconcile — the true one at 1 and the opposite
# at 0 (esmetrics.UpdateExternalSecretCondition) — so
# `{condition="Ready",status="False"}` is present with value 0 for every
# healthy ExternalSecret. A bare selector would match all 52 of them.
#
# `max`, not the `sum` the vendor dashboard uses. Both give 1 today,
# because ESO runs a single controller replica. During a Deployment
# rollout Prometheus can briefly scrape the old and the new pod at once,
# and `sum(...) == 1` would then evaluate to 2 and silently STOP matching
# — the alert would resolve itself in the middle of a failure. `max` is 0
# or 1 whatever the replica count.
#
# The gauge, not `externalsecret_sync_calls_error`. The counter was the
# other candidate, on the theory that a counter cannot flap the way a
# condition gauge might. Reading the controller says the opposite on both
# halves: the counter increments on strictly MORE paths than the condition
# does — a failed Get of the ExternalSecret itself, a failed Get or label
# Patch of the target Secret, an unresolvable SecretStore — and none of
# those call markAsFailed(), so none of them set Ready=False. A cache blip
# or an apiserver hiccup moves the counter and nothing else. It is the
# noisier signal, not the quieter one. And the gauge does
# not flap, because it is set to 1 only by markAsFailed() and cleared by
# the next successful reconcile. There is no unset state in between.
#
# The live numbers say the same thing, measured after #994 landed:
# `externalsecret_sync_calls_error` has exactly one series in the whole
# cluster, `ai/wiki-anthropic`, at 8 errors over 47 calls — and that
# ExternalSecret is Ready=True and has never been otherwise in the
# observation window. So on today's data the counter carries 8 events that
# correspond to no sync failure at all, and the gauge carries none.
#
# That observation window is now long enough to mean something, and it is
# the answer to the only open question this rule was held back for — how
# much the gauge flaps in ordinary operation. Measured 2026-09-26T08:02Z,
# over the 464 consecutive scrapes since #994 started collecting at
# 00:18Z:
#
# min_over_time(up{job="monitoring/external-secrets"}[8h]) = 1
# sum(increase(externalsecret_sync_calls_total[8h])) = 959
# count(increase(externalsecret_sync_calls_total[8h]) > 0) = 52
# count(max_over_time(
# externalsecret_status_condition{
# condition="Ready",status="False"}[8h]) == 1) = EMPTY
# sum(increase(externalsecret_sync_calls_error[8h])) = 0
#
# Every one of the 52 ExternalSecrets reconciled at least once in that
# window — 959 syncs, so the 1h and 10m refreshInterval cycles both turned
# over repeatedly — and across all of them the Ready gauge never reached 1
# in a single scrape. Zero flap amplitude, against real reconcile traffic
# rather than against silence. The counter was flat over the same window
# too, which is why 8 stays 8 above. `for: 15m` is therefore not damping
# anything measurable; it is derived from the failure modes below, and the
# data says no shorter window would have been noisy either.
#
# The 2026-09-20 `ai/wiki-embed-basic-auth` incident is evidence FOR this
# rule, not against it, though it reads the other way at first. That key
# sat in ESO retry backoff through ~10 failures and NEEDED A MANUAL
# force-sync (the incident is written up in
# clusters/homelab/uptime-monitor.yaml, as the in-repo proof for REN-100).
# Because it never self-healed, Ready=False was continuous from the first
# failure until a human intervened — hours, not minutes. `for: 15m` catches
# that with a wide margin. It was a stuck condition, not a flap; the flap
# reading would only hold if the retries had eventually succeeded on their
# own, and they did not.
- alert: ExternalSecretSyncFailing
expr: >-
max by (exported_namespace, name) (
externalsecret_status_condition{condition="Ready",status="False"}
) == 1
# 15m, and the reason it can be this relaxed is that a failed sync is
# not an outage: consumers keep using the last Secret ESO wrote, so what
# degrades is freshness, not availability. Nothing here needs catching
# inside a minute.
#
# It needs to be at least this long because the provider-error path
# returns the error to controller-runtime, which retries with
# exponential backoff rather than waiting out `refreshInterval`. A
# momentary OpenBao blip is therefore cleared within seconds and never
# reaches 15m, while a rolling restart of the 3-replica OpenBao
# StatefulSet is ridden out entirely — and that outage has its own
# alert (OpenBaoQuorumLost, critical at 2m), so nothing is hidden by
# waiting. 15m of continuous Ready=False means repeated failure.
for: 15m
labels:
severity: warning
annotations:
summary: "ExternalSecret {{ $labels.exported_namespace }}/{{ $labels.name }} is not syncing"
description: >-
ESO has been unable to sync this ExternalSecret for 15 minutes. The
Secret it manages still holds its last successfully synced value, so
consumers keep working — what breaks is that a rotation at the
provider no longer propagates, which surfaces much later as a
credential that stopped being accepted. Get the reason with
`kubectl -n {{ $labels.exported_namespace }} get externalsecret
{{ $labels.name }} -o jsonpath='{.status.conditions}'`; the common
causes are a key that was renamed or removed in OpenBao, a
ClusterSecretStore that cannot authenticate, and a target Secret
owned by something else.
# Dead-man's-switch, mirroring ExternalDNSMetricsMissing and
# OpenBaoMetricsMissing. The alert above can only fire on series that
# exist, so deleting the PodMonitor, reverting the `monitoring` ingress
# grant in infrastructure/network-policies/external-secrets-ns.yaml, or
# bumping the ESO chart onto a metrics port that no longer matches would
# all disarm it silently — which is the exact failure mode REN-103 exists
# to end. TargetDown does not cover this: it reports a target that exists
# and is failing, not the absence of a target.
- alert: ExternalSecretMetricsMissing
expr: absent(externalsecret_status_condition)
for: 15m
labels:
severity: warning
annotations:
summary: "No ExternalSecret metrics are reaching Prometheus"
description: >-
`externalsecret_status_condition` has no series. Either the ESO
controller is gone or its scrape stopped being collected, and
ExternalSecretSyncFailing is blind for as long as this is true.
Check the PodMonitor `monitoring/external-secrets` still selects the
controller pods, and that external-secrets-ns.yaml still admits
`monitoring` on TCP/8080.
# NOT covered here, deliberately, so nobody assumes it is:
#
# * `condition="Deleted"`. In chart 2.11.0 the only writer of that
# condition is the NotFound branch of Reconcile, which fabricates
# `Deleted=False` purely to make UpdateExternalSecretCondition drop
# the object's Ready series. It is a tombstone for an ExternalSecret
# that no longer exists, not a health state, and `Deleted=True` is
# never emitted at all — a rule on it would be dead code. The one such
# series in the cluster today (`ai/wiki-index-basic-auth`) is leftover
# process state for an ExternalSecret that moved namespace; it clears
# when the controller next restarts.
# * An ExternalSecret that exists in the cluster but was never
# reconciled emits no series at all, so nothing above can see it. That
# needs object-existence data — kube-state-metrics custom resource
# state — rather than ESO's own metrics, and is not in this change.
# * A sync that fails and then succeeds on its own inside 15m. This is a
# choice, not an oversight: the retry left the Secret holding a freshly
# synced value, so nothing degraded and there is nothing for a human to
# do. Alerting on it would mean paging for a self-healed event. The case
# that would justify revisiting is the same failure recurring for days
# without any single occurrence lasting 15m, which would show up as a
# climbing `externalsecret_sync_calls_error` on an ExternalSecret that
# is Ready=True. Nothing in the cluster shows that shape today, and the
# rule for it needs a measured recurrence rate rather than a guess.
@@ -0,0 +1,274 @@
# Per-object Flux readiness, and the groundwork for the Ready-but-frozen
# ImagePolicy signal (REN-102, closing REN-97).
#
# EVERYTHING HERE READS `gotk_resource_info`, WHICH IS A kube-state-metrics
# SERIES, NOT A FLUX CONTROLLER SERIES. apps/monitoring-rules/flux-podmonitor.yaml
# scrapes the controllers and yields reconcile durations and controller-runtime
# counters, but readiness is not on that endpoint at the pinned controller
# versions: `gotk_reconcile_condition` and `gotk_suspend_status` are declared by
# fluxcd/pkg/runtime and populated by nothing. The series these rules read are
# created by the `customResourceState` block in
# apps/monitoring/helmrelease-prometheus.yaml. If that block is removed, every
# rule below goes blind — which is what FluxImagePolicyMetricsMissing exists to
# say out loud.
#
# THE STALENESS HALF DELIBERATELY SHIPS WITHOUT AN ALERT. See the second group.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: flux
namespace: monitoring
labels:
# ruleSelector matches on this label; without it the rule is created but
# never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: flux.rules
rules:
# Why `max by (...)` and not the bare selector: it collapses the KSM
# target labels (`pod`, `instance`, `endpoint`, ...). Without it, a
# kube-state-metrics rollout changes `pod`, which resolves the alert on
# the old series and re-fires it on the new one — a fresh mail for an
# unchanged failure. KSM rolled 11 times in the last 30 days, so this is
# routine, not hypothetical.
#
# Why `ready=~"False|Unknown"` and not `ready="False"`: Flux sets Ready
# False on failure but Unknown while a reconcile is in flight, and an
# object wedged in Unknown is stuck just as hard as one that is False —
# this repo has already had a Progressing CephCluster deadlock the whole
# apps layer (see rook-servicemonitors.yaml). Matching the two values
# explicitly, rather than `ready!="True"`, keeps an object whose `ready`
# label is absent entirely (freshly created, condition not yet written)
# out of the alert.
#
# Why `suspended!="true"` and not an `unless` join: `suspended` is a
# label on the same Info series, so one matcher does what the withdrawn
# draft needed a second vector for. The `!=` form also covers kinds where
# the label is absent. There is no deleted-object guard because KSM stops
# emitting the series when the object goes away — under `gotk_*` that
# guard had to be written, here it is structural.
#
# for: 2h — 31 of the 34 ImageRepositories scan at `interval: 1h`
# (2 at 30m: shelly-manager, vsftpd; 1 at 10m: wiki), so 91% of the
# population cannot recover from a transient registry failure faster
# than one hour whatever this value says. Anything under 1h mails about
# a single self-healing missed scan. 2h means two consecutive failed
# scans. Detection latency added by going through KSM is one 30s scrape.
- alert: FluxImageAutomationNotReady
expr: >-
max by (customresource_kind, name, exported_namespace) (
gotk_resource_info{
customresource_kind=~"ImageRepository|ImagePolicy|ImageUpdateAutomation",
ready=~"False|Unknown",
suspended!="true"
}
)
for: 2h
labels:
severity: warning
annotations:
summary: "Flux {{ $labels.customresource_kind }}/{{ $labels.name }} has not been Ready for 2h"
description: >-
An image-automation object has been not-Ready for two reconcile
intervals. Nothing is broken in the running cluster, but the image
it governs has stopped moving, security rebuilds included.
`flux get image all -n {{ $labels.exported_namespace }}` gives the
reason; the usual causes are registry credentials, a rate limit, or
a tag format the policy's `pattern`/`extract` no longer matches.
flux-notifications/alerts.yaml already mailed once at the moment of
transition — this is the "still broken two hours later" signal that
edge-triggered mail cannot give.
# The exact negation of the matcher above, so a kind added to
# helmrelease-prometheus.yaml's customResourceState config is covered by
# one of these two rules without anyone remembering to widen a list.
#
# for: 1h, derived from the intervals actually in this repo: all 33
# Kustomizations are at 10m (six retries inside the hour), all 25
# HelmReleases at 30m (two), and the flux-system GitRepository at 1m0s
# (sixty). The dominant non-failure source of ready="False" is a
# `dependsOn` wait, whose `reason` is not a label and so cannot be
# filtered out; an hour clears a whole-cluster reconcile chain.
#
# KNOWN TRADE, stated rather than hidden: this matcher also picks up the
# 17 HelmRepository and 4 OCIRepository objects, and every one of those is
# at `interval: 24h`. A failed index or manifest fetch there cannot
# self-heal inside an hour, so a transient failure yields up to two mails
# (repeat_interval is 12h) before the next scheduled reconcile. That is
# accepted as correct — a chart source stale for an hour is worth knowing
# — and it is real information rather than a blip. If it turns out to be
# the noisy one, the fix is a third rule for the 24h-interval kinds, not
# a global bump of this value, which would make Kustomization alerts
# useless.
- alert: FluxResourceNotReady
expr: >-
max by (customresource_kind, name, exported_namespace) (
gotk_resource_info{
customresource_kind!~"ImageRepository|ImagePolicy|ImageUpdateAutomation",
ready=~"False|Unknown",
suspended!="true"
}
)
for: 1h
labels:
severity: critical
annotations:
summary: "Flux {{ $labels.customresource_kind }}/{{ $labels.name }} has not been Ready for 1h"
description: >-
A Flux object outside image automation has been not-Ready for an
hour, so the desired state in git is not reaching the cluster.
`flux get all -A --status-selector ready=false` lists them with a
reason. For a Kustomization or HelmRelease this means a rollout is
stuck; for a HelmRepository or OCIRepository it means the chart or
artifact index is stale and dependent releases will not upgrade.
# THE DEAD MAN'S SWITCH, and the reason it is safe here and was not in
# #992. This asserts that the 34 ImagePolicy series exist — and they
# exist only because this change created them. Merged against `gotk_*`
# alone, as the withdrawn draft had it, this rule would have been
# permanently true and mailed admin@renner.uno every 12h forever.
#
# Its value is catching the removal of the customResourceState block, or
# a malformed edit to it, which would otherwise take all three rules
# above silently blind. `KubeStateMetricsDown` and `TargetDown` overlap it
# when the whole pod is down; neither notices a config that parses but
# emits nothing.
#
# for: 30m rides out the kube-state-metrics pod roll that applying this
# change itself causes, and any later roll.
- alert: FluxImagePolicyMetricsMissing
expr: absent(gotk_resource_info{customresource_kind="ImagePolicy"})
for: 30m
labels:
severity: critical
annotations:
summary: "Flux per-object readiness metrics have stopped being exported"
description: >-
kube-state-metrics is exporting no ImagePolicy series, so
FluxImageAutomationNotReady and FluxResourceNotReady cannot fire and
Flux readiness is unmonitored. Check the `customResourceState` block
in apps/monitoring/helmrelease-prometheus.yaml, then the
kube-state-metrics pod log — a custom-resource-state config that
fails to parse is logged there and yields no series.
# The Ready-but-frozen ImagePolicy signal: a policy whose `filterTags` or
# semver range excludes everything new keeps resolving the tag it resolved
# months ago, holds Ready=True the whole time, and no readiness rule can see
# it. `tika.yaml`'s own comment names the case.
#
# THIS GROUP DELIBERATELY CONTAINS NO ALERT, AND THAT IS A MEASURED DECISION,
# NOT AN OMISSION. Every candidate threshold was tested against the real
# release history and the real registries on 2026-09-26:
#
# * A flat "frozen for N days" duration does not exist. Over the 191
# automated bump commits in the 147 days to 2026-09-25, gatus went 127
# days between bumps and paperless-ngx 126, both provably healthy — they
# bumped. Six policies have never bumped at all. Any N below 127 fires on
# the healthy pair; any N above 147 is longer than the whole observation
# window.
#
# * The "tag count grew >= 10%" comparator from this card's first design is
# wrong, and the registries say so. Of the 18 policies frozen 14 days or
# more, a 10% threshold fires on exactly three — gotenberg (+87 tags/14d
# on 526), valkey (+68 on 301), eclipse-mosquitto (+13 on 90) — and all
# three churn variant tags their filters reject on purpose. It does NOT
# fire on tika (+4 on 136 = 3.0%), which is the canonical case this card
# was opened for.
#
# * Relaxing to "grew by >= 1 tag" does catch tika, but then also fires on
# those same three plus velero-plugin-for-aws, whose `1.13.x` pin is
# deliberate. Alertmanager routes every severity to the same email
# receiver with repeat_interval: 12h, so that is four standing mails
# every twelve hours, indefinitely, for four non-faults.
#
# * `.status.lastScanResult.latestTags` looks like the way out and is not:
# the CRD defines it as "the first 10 tags when sorting all the tags in
# descending ALPHABETICAL order", which is not newest-first for any tag
# scheme in this repo.
#
# So the recorded series below materialise the candidate set and, more to the
# point, START THE HISTORY that a threshold has to be derived from — a real
# time series of tagCount per repository, which has never existed here. The
# alert is deferred to a follow-up card that reads 14+ days of it. Recording
# rules cannot mail anyone, so this costs nothing while it warms up.
#
# interval: 15m is LOAD-BEARING and is declared here rather than inherited.
# Global evaluationInterval is 30s and no other rule group in this directory
# sets one (45 of the 46 loaded groups run at 30s). The `>= 1250` warm-up
# guard below counts samples, so at 30s it would be satisfied in 10.4 hours
# instead of 13 days and the frozen series would read true for policies that
# had not been observed frozen at all.
- name: flux-image-policy-staleness.rules
interval: 15m
rules:
# Stage one exists only to strip the KSM target labels. Counting distinct
# tags means counting distinct series, and `gotk_resource_info` carries
# `pod` and `instance` — there were 11 distinct kube-state-metrics pod
# identities in the last 30 days, so counting the raw series would report
# "several tags seen" after every roll, including the roll this change
# itself causes, and would keep reporting it for the whole window.
- record: flux:image_policy_tag:info
expr: >-
max by (name, exported_namespace, source_name, tag) (
gotk_resource_info{customresource_kind="ImagePolicy"}
)
# Distinct resolved tags seen in a trailing day. A tag change retires one
# series and starts another, so `count by (...)` over `count_over_time`
# is a count of tag values, and `== 1` means the tag did not move.
- record: flux:image_policy_tags_seen:1d
expr: >-
count by (name, exported_namespace, source_name) (
count_over_time(flux:image_policy_tag:info[1d])
)
# The repository side, keyed by `source_name` so it joins on the actual
# `spec.imageRepositoryRef.name` reference rather than on the convention
# that a policy is named after its repository. All 34 pairs match by name
# today, so this is drift insurance, not a bug fix.
- record: flux:image_repository_tag_count:1d
expr: >-
max by (source_name, exported_namespace) (
label_replace(
max_over_time(gotk_image_repository_tag_count[1d]),
"source_name", "$1", "name", "(.+)"
)
)
# The candidate set: resolved tag unmoved for 14 days while the
# repository's tag count is genuinely higher than it was.
#
# 14d, not the 30d this card was first designed around, because 30d does
# not fit. `retentionSize: 30GB` binds long before `retention: 90d`:
# (time() - prometheus_tsdb_lowest_timestamp/1000)/86400 measured 26.63
# days on 2026-09-26, at 84.5% of the size cap and growing ~1.09 GB/day
# (REN-107). A 30d window can never be satisfied. 14d uses 53% of that
# floor.
#
# >= 1250 is the warm-up guard and its arithmetic is: at interval 15m a
# full 14d window holds 14*24*4 = 1344 samples, so 1250 is 93% of a full
# window = 13.02 days of history, leaving ~23h of slack for Prometheus
# restarts and KSM rolls. Without it the series would read true on day
# one, when "unmoved for 14 days" is simply not yet knowable. 13.02 days
# against a 26.63-day floor is 2.0x margin; re-check it with the
# lowest_timestamp query above if retention is ever cut.
#
# `last_over_time - min_over_time >= 1` and not a max/min ratio: a ratio
# is direction-agnostic and is satisfied by a tag count that DROPPED
# (registry pruning, an edited exclusionList, a partial scan) exactly as
# by one that grew. Comparing the current value against the window
# minimum only counts net growth.
- record: flux:image_policy_tag_frozen:14d
expr: >-
(
max_over_time(flux:image_policy_tags_seen:1d[14d]) == 1
and
count_over_time(flux:image_policy_tags_seen:1d[14d]) >= 1250
)
and on (source_name, exported_namespace) (
(
last_over_time(flux:image_repository_tag_count:1d[1h])
- min_over_time(flux:image_repository_tag_count:1d[14d])
) >= 1
)
@@ -0,0 +1,119 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: home-assistant
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: home-assistant.rules
rules:
# The four services the house stops working without (REN-244). Before
# this group the only signal for any of them was the cluster-wide
# KubePodNotReady: 15m, severity warning, and not named per service.
#
# SHAPE. One alert per service, with `or` joining the ways it can be
# down, so one outage is one alert and not one per symptom. All four are
# `for: 5m`, severity critical. Alertmanager has one receiver today
# (email to the owner, apps/monitoring/helmrelease-prometheus.yaml), so
# "critical" does not change where these go today. It does keep them
# out of reach of the inhibit rules, which only silence warning/info.
#
# 5m IS SHORTER THAN A SLOW HA UPGRADE. Home Assistant can take up to
# 10 min to come back after an automatic image bump. REN-244 asks for
# detection in 5 min on purpose; a HomeAssistantDown that resolves on
# its own a few minutes later, right after an upgrade, is that case.
#
# Tests: tests/home-assistant_test.yaml.
- alert: HomeAssistantDown
# `absent` covers the target vanishing entirely (pod gone, Service
# has no endpoints during a Recreate rollout); `== 0` covers a target
# that exists but does not answer /api/prometheus.
#
# `max by (job, namespace)` gives both branches the same label set.
# Without it the alert changes identity when a pod that was gone
# comes back failing, and the new identity restarts the `for` timer.
expr: |
max by (job, namespace) (up{job="home-assistant", namespace="home-assistant"}) == 0
or absent(up{job="home-assistant", namespace="home-assistant"})
for: 5m
labels:
severity: critical
annotations:
summary: "Home Assistant has been down for 5 minutes"
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
- alert: HomeAssistantRecorderDBUnhealthy
# ha-pg, the recorder's CNPG cluster. Fires when no instance is the
# primary, or when fewer than all instances have Postgres up.
#
# cnpg_collector_up, not `up`: the exporter lives in the instance
# manager, which keeps serving /metrics while Postgres itself is down.
# `up` would read 1 through exactly the outage this rule is for.
#
# `role` comes from podTargetLabels on the cnpg-ha-pg PodMonitor
# (cnpg-podmonitors.yaml). It flips on failover, so the instance count
# is taken across roles. Both branches come out as {} (`absent` of a
# comparison carries no labels), so it is one alert whichever of them
# is true, and a failover does not start a second one.
#
# THE 2 IS `spec.instances` in
# kubernetes/apps/home-assistant/ha-pg-cluster.yaml. Change both
# together, or this pages forever (more instances is fine; fewer is
# not).
expr: |
count(cnpg_collector_up{job="home-assistant/cnpg-ha-pg"} == 1) < 2
or absent(cnpg_collector_up{job="home-assistant/cnpg-ha-pg", role="primary"} == 1)
for: 5m
labels:
severity: critical
namespace: home-assistant
annotations:
summary: "Recorder database ha-pg has no primary or is missing an instance"
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
- alert: MosquittoDown
# Pod readiness only: there is no broker exporter, and no mosquitto_*
# or mqtt_* series in Prometheus.
#
# The pod regex is the Deployment's name shape
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
# briefly Ready must not hide a broker that is down.
expr: |
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
for: 5m
labels:
severity: critical
# `absent` of a comparison carries no labels; set it for routing.
namespace: home-assistant
annotations:
summary: "Mosquitto (MQTT broker) has not been ready for 5 minutes"
description: "No mosquitto pod in namespace home-assistant is Ready. Zigbee2MQTT, Shelly and every other MQTT device lose their link to Home Assistant while this lasts, so Zigbee2MQTTDownOrCoordinatorOffline may fire alongside it for the same cause."
- alert: Zigbee2MQTTDownOrCoordinatorOffline
# Two ways Zigbee stops working: the pod is not Ready, or it runs but
# has lost the coordinator / MQTT bridge. The second is read from
# Home Assistant's own bridge sensor, so it disappears when HA is down;
# HomeAssistantDown covers that case and this rule stays silent
# instead of paging twice.
#
# Both branches come out as {} (`absent` of a comparison carries no
# labels; the sensor branch is reduced with `max`), for the same
# reason as in HomeAssistantDown: pod-not-ready turning into
# bridge-offline is one outage and keeps one `for` timer.
expr: |
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"zigbee2mqtt-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
or max(homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state"}) == 0
for: 5m
labels:
severity: critical
namespace: home-assistant
annotations:
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
@@ -0,0 +1,153 @@
# Catches a Prometheus retention setting that is configured but NOT IN FORCE.
#
# kube-prometheus-stack takes both `retention` (time) and `retentionSize`
# (bytes) and passes both to Prometheus, which honours WHICHEVER BINDS FIRST.
# When the size cap binds first, the time setting silently becomes decoration:
# Prometheus drops its oldest blocks, every query still succeeds, and the only
# symptom is that a range longer than the real window quietly returns less data
# than asked for. Nothing in the 182 alert rules this cluster already loads
# reports that — verified against the live rule set when this was written.
#
# That is not hypothetical here. Measured through the read-only proxy on
# 2026-09-26 with `retention: 90d` and `retentionSize: 30GB` in
# apps/monitoring/helmrelease-prometheus.yaml (REN-107):
#
# storage.tsdb.retention.size = 30GiB -> 32,212,254,720 bytes
# storage.tsdb.retention.time = 90d -> 7,776,000 s
# actual window held -> 2,291,239 s (26.5 days, 29% of 90d)
#
# and size eviction has been firing on a ~7-day cycle for at least as long as
# the TSDB retains history. At 2026-09-19 23:00->23:30 the store went 28.16 GB
# -> 21.14 GB, `prometheus_tsdb_size_retentions_total` went 0 -> 1, and the
# window held collapsed 27.21 -> 20.48 days. The effective retention is a
# 20.5-27.2 day saw-tooth, not 90 days and not any single number.
#
# WHY NOT JUST ALERT ON prometheus_tsdb_size_retentions_total > 0
#
# Because that is a PROCESS-LIFETIME counter and it lies by omission. It resets
# to 0 on every Prometheus restart, and this Prometheus restarts every few days
# (seven distinct pod IPs in the 26 days of history the TSDB holds — the chart
# is pinned `>=72.0.0`, so Flux upgrades it whenever upstream releases). Each
# fresh pod reads 0 for the first ~6 days of its life even though the cluster
# has been evicting data continuously for a month. REN-107 was originally filed
# as "the cap will bind in ~3 days" on exactly that reading, taken on a pod that
# had started 5 days earlier. A spot read of this counter cannot distinguish
# "never evicted" from "evicted, then restarted".
#
# So this rule does not read the counter at all. It compares the two things
# that are true regardless of process age: the window actually on disk, and the
# limits the running process reports.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: prometheus-retention
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: prometheus-retention.rules
rules:
- alert: PrometheusRetentionNotInForce
# NO HARDCODED 90d / 30GB. Both limits are read from the running
# process via `prometheus_tsdb_retention_limit_seconds` and
# `prometheus_tsdb_retention_limit_bytes`, which Prometheus exports
# from the flags it actually started with. Edit the HelmRelease and
# this rule follows; it cannot drift out of sync with the config the
# way a literal threshold would. That matters because a literal here
# would be a second copy of the very setting whose accuracy this rule
# exists to police.
#
# TWO CLAUSES, AND THE SECOND IS THE GUARD
#
# Clause 1 — the window on disk is materially shorter than the
# configured time retention (<90% of it).
#
# Clause 2 — the store has reached 80% of the configured SIZE cap at
# some point in the last 7 days.
#
# Clause 1 alone would fire on a legitimately young TSDB: a freshly
# provisioned PVC holds one day of data against a 90d setting and
# nothing is wrong. Clause 2 is what distinguishes "not full yet" from
# "full, and the size cap is what is actually in force" — the store can
# only be near the byte cap AND short of the time window if bytes are
# the binding constraint. Dropping either clause breaks the rule in a
# different direction; verified both ways against the live TSDB before
# merge (see the PR).
#
# max_over_time(...[7d:1h]) rather than the instantaneous ratio,
# because size eviction is a SAW-TOOTH: the store climbs to ~87% of
# the cap, sheds a compacted block, and drops back to ~66%. An instant
# ratio would resolve and re-fire on that ~7-day cycle and email twice
# a week about an unchanging condition. The 7d window spans a full
# tooth, so the alert is continuous while the condition is.
#
# The 7d PEAK IS DIVIDED BY THE CAP IN FORCE NOW, not by the cap in
# force when the peak was recorded. The difference is the day the
# config is fixed: REN-107 moves 90d/30GiB to 30d/40GiB, the window
# on disk is still ~21 days (so clause 1 stays true until it grows
# past 27), and last week's 28 GB peak was 87% of the OLD cap. A
# per-step ratio would keep this firing for up to 7 days after the
# fix that resolves it; against the new cap the same peak is 66% and
# it clears at once. tests/prometheus-retention_test.yaml holds both.
#
# Everything is aggregated (min/sum/max) so the expression carries NO
# instance or pod label. That is deliberate and load-bearing: it makes
# the 7d lookback survive the pod restarts described in the header,
# which would otherwise start a new series and reset the window.
# Confirmed on the live TSDB — the 0.874 peak this returns today was
# recorded by a pod that no longer exists. There is exactly one
# Prometheus here, so losing the per-instance breakdown costs nothing.
expr: |
(
min(time() - prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus"})
< 0.9 * min(prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus"})
)
and
(
max_over_time(
sum(
prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus"}
+ prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus"}
+ prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus"}
)[7d:1h]
)
/ max(prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus"})
> 0.8
)
# Long, because this is a configuration fact rather than an incident.
# Nothing degrades at the moment it becomes true and nothing is fixed
# by waiting, so there is no value in a fast signal — only in not
# paging during the minutes after a restart while the aggregation
# settles. It clears within one evaluation once the config is honest.
for: 1h
labels:
severity: warning
# Static, and true: the Prometheus this describes runs in
# `monitoring`. The expression aggregates the real label away (see
# above), and the chart's inhibit rules plus Alertmanager's
# `group_by` both key on `namespace` — without it this alert groups
# under the empty namespace, unlike every other alert here.
namespace: monitoring
annotations:
summary: "Prometheus retention setting is not in force — size cap binds first"
# $value is clause 1's left-hand side: the window actually on disk,
# in seconds. The configured pair is deliberately NOT templated in
# via `query` — that runs on every evaluation and can fail the rule
# itself. `runtimeinfo.storageRetention` reports both as one string
# ("90d or 30GiB"), which is the cheaper and more direct read.
description: >-
Prometheus is holding only {{ $value | humanizeDuration }} of history while its
configured time retention is longer, and the store has been within 20% of its
configured size cap in the last 7 days. The size cap is therefore what is actually
in force and the `retention` value in
kubernetes/apps/monitoring/helmrelease-prometheus.yaml is not being honoured —
queries over a longer range return less data than requested, silently and
successfully. Confirm the running pair with
`GET /api/v1/status/runtimeinfo` -> `storageRetention`, then either raise
`retentionSize` (and the PVC, which must stay ahead of it) or lower `retention`
to a figure the storage budget can actually hold. Do not read
`prometheus_tsdb_size_retentions_total` to decide whether this is real — it resets
on every restart. Background: REN-107.
@@ -0,0 +1,127 @@
# promtool unit suites for the PrometheusRules in this directory
`promtool test rules` suites, run on every pull request that touches
`kubernetes/**` by the `promtool` step in
[`.woodpecker/ci-manifests-test.yml`](../../../../.woodpecker/ci-manifests-test.yml)
via [`scripts/ci/promtool-rules.sh`](../../../../scripts/ci/promtool-rules.sh).
## Why this exists
Before this, CI proved that every `PrometheusRule` in the tree assembled into
valid YAML and nothing else. It did not check that an expression parses, that a
label join matches anything, or that a `for` window behaves the way its comment
claims.
That was not theoretical. On [REN-129](/REN/issues/REN-129) the surge rule in
`prometheusrule-cnpg-backup.yaml` was written `for: 2m` and reviewed at that
value. Against Iris's seven measured nights it fires on six of them — two owner
emails a night, 04:16 and 04:22 local, for a WAL backlog that clears itself in
under ten minutes. It was caught by a human writing the arithmetic out by hand,
twice, in two separate runs, and throwing the work away both times because
there was nowhere in the repo to put it. [REN-152](/REN/issues/REN-152) then
retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
## Layout
| File | What it is |
| --- | --- |
| `cnpg-backup_test.yaml` | The suite. **Generated** — see below. |
| `generate-cnpg-backup-test.py` | Regenerates it from the REN-129 measurement table. |
| `external-secrets_test.yaml` | Hand-written suite for `prometheusrule-external-secrets.yaml` (REN-103). |
| `home-assistant_test.yaml` | Hand-written suite for `prometheusrule-home-assistant.yaml` (REN-245). |
| `ceph_test.yaml` | Hand-written suite for the `CephPoolLostRedundancyBlind` canary in `prometheusrule-ceph.yaml` (REN-93). |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
Name it `<something>_test.yaml` in this directory. Discovery is by that suffix,
and `promtool-rules.sh` fails if it finds none, so a suite in the wrong place
is a red pipeline rather than a silent gap.
**`rule_files` entries are relative to this directory, not the repo root.**
promtool joins `dirname(suite)` onto each entry, so a manifest in the parent
directory is `../name.yaml`. Getting this wrong is not a test failure: promtool
prints `WARNING: no file match pattern …`, loads **zero rules**, and every
`exp_alerts: []` assertion then passes against an empty rule set. The first
draft of the suite here shipped exactly that way — ninety silence assertions,
green, testing nothing. `promtool-rules.sh` resolves every entry itself before
running promtool and fails on a miss, because promtool has no flag that makes
it fatal.
`promtool` cannot read a `PrometheusRule` directly: it wants a bare `groups:`
document, and a `PrometheusRule` carries that under `spec:`. The script strips
`spec:` into a temporary mirrored tree with the same relative layout and runs
there, so `../name.yaml` resolves in the mirror exactly as it reads here.
Nothing is written back into the checkout.
## Regenerating `cnpg-backup_test.yaml`
```sh
python3 kubernetes/apps/monitoring-rules/tests/generate-cnpg-backup-test.py \
> kubernetes/apps/monitoring-rules/tests/cnpg-backup_test.yaml
```
and commit the result. `--report` prints the reconstruction self-check table
instead of the suite.
The generator exists so the numbers in the suite are auditable: the seven
measured nights are rebuilt from the `(peak, peak minute, fill, drain)` tuples
Iris published on REN-129, not hand-typed, so a reader can check them against
the measurement rather than taking them on trust. It is not a hermetic
artifact, and **CI does not regenerate and diff it** — the promtool image has
no Python, and byte-equality with a generator is not what stops a suite from
being weakened. The mutation phase is.
Two things the generator refuses to emit, both because they happened:
- a window that does not cover the bursts. An earlier revision had `T0` at
`8 * 60 + 8` (08:08Z) instead of `2 * 60 + 8`, seven hours after every burst,
so all seven nights rebuilt as forty zeroes and their silence assertions
passed without touching a threshold. `assert_window_covers_bursts()` now
fails instead.
- a night that clears the threshold *sooner* than the measured one, which would
be a weaker silence test than the night it claims to stand for. Longer is
fine and expected — a linear decay at the published mean drain rate holds the
tail above 5 a minute or two longer than the real trace on four of the seven
nights, which makes the suite harder to pass, not easier.
## Negative controls
A suite that passes proves the rules match the assertions. It does not prove
the assertions reach the rules — both failure modes above were green. So
`promtool-rules.sh` runs a second phase: for each entry in `mutations.txt` it
breaks one value the rules depend on and requires the suites to **fail**. A
mutation nothing notices is reported as a hole in the suite.
This is also the answer to "why is that case in there". `backlog-for-12m` is
silent on all seven reconstructed nights and fires only on the raw 2026-09-11
worst-night case, so deleting that case makes `for: 20m` reducible to `12m`
with the suite still green. The control is what makes the case load-bearing.
Mutations are `name|manifest-path|sed-program`, applied to the stripped copy in
the mirror (two spaces shallower than the manifest on disk — anchor on
`^[[:space:]]*`). A program that matches nothing is a failure, not a skip:
otherwise renaming a rule quietly turns its control into a no-op.
## Reading `for` margins
Margin is **pending** time, which is one minute less than the run length. An
alert goes pending at the first evaluation where the expression is true and
fires when `now - activeAt >= for`, so N consecutive one-minute samples above a
threshold accumulate N-1 minutes. A trace with 5 samples above 5 does not fire
`for: 5m`; it fires `for: 4m`. The `>5 pend` column in `--report` is the number
to compare a `for` against — not the sample count next to it.
## Running it locally
```sh
# promtool must be 3.x: the cluster runs kube-prometheus-stack >= 72.0.0, and
# alert `for` evaluation is version-sensitive, so a 2.x promtool would be
# checking a different evaluator than production. The script enforces this.
sh scripts/ci/promtool-rules.sh
```
The script's own guards — the discovery floors, the `rule_files` resolution, the
mutation phase — are tested offline and without promtool by
`scripts/ci/test-promtool-rules.sh`, which runs on every pull request via
`.woodpecker/ci-scripts-test.yml`.
@@ -0,0 +1,176 @@
# promtool test rules for the CephPoolLostRedundancyBlind canary in
# prometheusrule-ceph.yaml (REN-93).
#
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
# scripts/ci/promtool-rules.sh, which strips `spec:` from the manifest into a
# mirrored tree — hence `../` below, resolved from this file's directory. See
# README.md here. The negative controls for this suite are the `ceph-*` entries
# in mutations.txt.
#
# WHAT IT IS FOR. `promtool check rules` proves the canary parses. It cannot
# prove the canary fires when the join breaks, or stays quiet when the join is
# fine — every scenario below is well-formed PromQL in both the passing and the
# failing case; only the synthetic data changes, and only evaluation sees it.
#
# `exp_annotations` must match the RENDERED annotations exactly, so the strings
# below are the rule's with `{{ $labels.pool_id }}` substituted. Tedious to keep
# in sync, and that is the point: an annotation edited into nonsense fails here
# instead of in an email.
rule_files:
- ../prometheusrule-ceph.yaml
evaluation_interval: 1m
tests:
# A. Healthy replicated pool, metadata intact. Neither alert may fire.
# used/stored = 3.0, so well clear of the 1.5 threshold, and the join
# resolves — so the canary has nothing to report either. This is the
# scenario that fails if someone inverts a comparison.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '6000000+0x200'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: CephPoolLostRedundancyBlind
exp_alerts: []
- eval_time: 120m
alertname: CephPoolLostRedundancy
exp_alerts: []
# B. Real redundancy loss, metadata intact: used/stored = 1.0.
# The critical alert must fire AND the canary must stay quiet. The second
# half is the load-bearing one — a canary that also fires on a real
# redundancy loss would mask the alert it exists to protect, and
# `absent()` over an expression that IS producing series is the only thing
# stopping that. Also pins the `for: 30m`: 120m is well past it.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: CephPoolLostRedundancy
exp_alerts:
- exp_labels:
severity: critical
pool_id: "1"
exp_annotations:
summary: "Ceph replicated pool 1 is running unreplicated"
description: "Replicated pool 1 has a replication factor below 1.5, meaning it is down to a single copy and one OSD loss destroys data. No replicated pool is meant to be unreplicated — it also blocks OSD maintenance for as long as it lasts, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Erasure-coded pools are out of scope for this rule and are covered by POOL_NO_REDUNDANCY via CephHealthWarn (REN-88). Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- eval_time: 120m
alertname: CephPoolLostRedundancyBlind
exp_alerts: []
# C, D and E are the same failure seen three ways: the pool is genuinely at
# replication 1.0, and CephPoolLostRedundancy produces NOTHING. Each asserts
# `exp_alerts: []` for the critical alert — that empty list is the silent
# failure being surfaced, not an absence of interest — and asserts the canary
# fires instead. Remove the canary from the rule file and all three fail.
#
# C. `type` renamed to `pool_type` by a Rook or Ceph upgrade.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_metadata{pool_id="1",pool_type="replicated",name="rbd"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: CephPoolLostRedundancy
exp_alerts: []
- eval_time: 120m
alertname: CephPoolLostRedundancyBlind
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
# D. Label VALUE changed rather than the label name: `replicated` ->
# `replica`. Every label the rule names still exists, which is why a check
# on label presence alone would not catch this.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_metadata{pool_id="1",type="replica",name="rbd"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: CephPoolLostRedundancy
exp_alerts: []
- eval_time: 120m
alertname: CephPoolLostRedundancyBlind
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
# E. `pool_id` renamed to `pool` on the METADATA side only. Both metrics
# exist, both label values are unchanged, and `type="replicated"` still
# matches — only the `on(pool_id)` join key is gone from one side, so the
# join matches nothing. The subtlest of the three, and the one a human
# reading the expression is least likely to spot.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x200'
- series: 'ceph_pool_metadata{pool="1",type="replicated",name="rbd"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: CephPoolLostRedundancy
exp_alerts: []
- eval_time: 120m
alertname: CephPoolLostRedundancyBlind
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
# F. mgr FAILOVER, which is the noise case the canary's `for: 1h` exists for.
# All ceph_* metrics stop at t=30m and never return; with the 5m lookback
# they read as present until t=35m, so the canary goes pending at ~t=36m.
# It must NOT be firing at 75m (a failover-length gap — the exporter moved
# through 17 mgr instances in 30d) and MUST be firing by 95m. Shorten the
# `for:` and the 75m assertion fails; lengthen it and the 95m one does.
# Both eval_times were checked against promtool 3.15.0 rather than derived,
# because the exact ActiveAt depends on lookback and evaluation alignment.
- interval: 1m
input_series:
- series: 'ceph_pool_bytes_used{pool_id="1"}'
values: '6000000+0x30'
- series: 'ceph_pool_stored{pool_id="1"}'
values: '2000000+0x30'
- series: 'ceph_pool_metadata{pool_id="1",type="replicated",name="rbd"}'
values: '1+0x30'
alert_rule_test:
- eval_time: 75m
alertname: CephPoolLostRedundancyBlind
exp_alerts: []
- eval_time: 95m
alertname: CephPoolLostRedundancyBlind
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "CephPoolLostRedundancy cannot fire — its metrics no longer join"
description: "The CephPoolLostRedundancy alert has been unable to produce any series for an hour, so a `critical` storage alert is silently dark. It joins ceph_pool_bytes_used / ceph_pool_stored against ceph_pool_metadata{type=\"replicated\"} on `pool_id`; one of those metrics, the `pool_id` label, the `type` label, or the `replicated` value has stopped matching — most likely a Rook or Ceph upgrade, or a relabel rule. Check what the exporter emits now with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail` and by querying `ceph_pool_metadata` directly, then fix the join in kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml — do not delete this canary (REN-93)."
@@ -0,0 +1,431 @@
# GENERATED by generate-cnpg-backup-test.py — see README.md in this
# directory before editing by hand.
#
# Unit tests for kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml.
# Every assertion below is either one of Iris's seven measured nights
# (REN-129) rebuilt from its published (peak, fill, drain) numbers, the
# worst night in Prometheus' retention as raw samples (REN-152), an
# adversarial combination of the extremes of those numbers, or a failure
# this cluster has not had but the rule exists to catch.
#
# `rule_files` entries are resolved RELATIVE TO THIS FILE'S DIRECTORY, not
# to the working directory promtool was invoked from — promtool joins
# dirname(testfile) onto each entry. So this is `../`, and it resolves in
# the mirrored tree scripts/ci/promtool-rules.sh builds, where the
# `spec:`-stripped manifests sit in the same layout as the real ones.
#
# A path that does NOT resolve is only a WARNING to promtool: it loads zero
# rules, every exp_alerts: [] assertion passes against nothing, and the
# suite exits 0. promtool-rules.sh resolves every entry itself before
# running and fails on a miss; that check is the only thing standing
# between a typo here and a green pipeline that tests nothing.
rule_files:
- ../prometheusrule-cnpg-backup.yaml
evaluation_interval: 1m
tests:
# ---------------------------------------------------------------------
# 1-7. THE SEVEN MEASURED NIGHTS. The rule must stay silent through
# every one of them. The retired depth rule (`> 64`) shipped for review
# at `for: 2m` and fired on 6 of these 7 nights; REN-152 removed it.
# ---------------------------------------------------------------------
# 2026-09-20: the nightly Home Assistant recorder purge at 04:12 local.
# peak 106 at 02:15Z, fill 35.3/min, drain 13.2/min -> 10 samples above 5 (9m pending vs for: 20m).
- name: nightly burst 2026-09-20 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 35 71 106 93 80 66 53 40 27 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 106
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-21: the nightly Home Assistant recorder purge at 04:12 local.
# peak 100 at 02:15Z, fill 33.3/min, drain 14.3/min -> 9 samples above 5 (8m pending vs for: 20m).
- name: nightly burst 2026-09-21 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 33 67 100 86 71 57 43 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 100
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 14
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-22: the nightly Home Assistant recorder purge at 04:12 local.
# peak 101 at 02:15Z, fill 33.7/min, drain 14.4/min -> 9 samples above 5 (8m pending vs for: 20m).
- name: nightly burst 2026-09-22 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 34 67 101 87 72 58 43 29 15 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 101
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 15
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-23: the nightly Home Assistant recorder purge at 04:12 local.
# peak 119 at 02:15Z, fill 39.7/min, drain 14.9/min -> 10 samples above 5 (9m pending vs for: 20m).
- name: nightly burst 2026-09-23 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 40 79 119 104 89 74 59 44 30 15 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 15
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-24: the nightly Home Assistant recorder purge at 04:12 local.
# peak 56 at 02:14Z, fill 18.7/min, drain 14.0/min -> 6 samples above 5 (5m pending vs for: 20m).
- name: nightly burst 2026-09-24 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 19 37 56 42 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:14Z, backlog 56
- eval_time: 6m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:17Z, backlog 14
- eval_time: 9m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:19Z, backlog 0
- eval_time: 11m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-25: the nightly Home Assistant recorder purge at 04:12 local.
# peak 91 at 02:15Z, fill 22.8/min, drain 13.0/min -> 10 samples above 5 (9m pending vs for: 20m).
- name: nightly burst 2026-09-25 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 23 46 68 91 78 65 52 39 26 13 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 91
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:21Z, backlog 13
- eval_time: 13m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 0
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 2026-09-26: the nightly Home Assistant recorder purge at 04:12 local.
# peak 113 at 02:15Z, fill 28.5/min, drain 14.1/min -> 11 samples above 5 (10m pending vs for: 20m).
- name: nightly burst 2026-09-26 — recorder purge, benign, self-clearing
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 28 57 86 113 99 85 71 57 42 28 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 113
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:22Z, backlog 14
- eval_time: 14m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:24Z, backlog 0
- eval_time: 16m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# ---------------------------------------------------------------------
# 8. THE ADVERSARIAL ENVELOPE. A night that did not happen, built from
# the worst fill (39.7/min, 09-23), the highest peak (119, 09-23) and
# the slowest drain (13.0/min, 09-25) in the week. 11 samples above 5,
# 10m pending against `for: 20m`.
# ---------------------------------------------------------------------
# Synthetic: max fill 39.7 + max peak 119 + min drain 13.0.
# peak 119 at 02:15Z, fill 39.7/min, drain 13.0/min -> 11 samples above 5 (10m pending vs for: 20m).
- name: adversarial envelope — worst measured fill and peak, slowest drain
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08Z .. 02:47Z, one sample per minute
values: '0 0 0 0 0 40 79 119 106 93 80 67 54 41 28 15 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# The cumulative archived counter on the same metric name. The
# rules must never read it; see mutations.txt:no-value-selector.
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
# 02:15Z, backlog 119
- eval_time: 7m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:23Z, backlog 15
- eval_time: 15m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:26Z, backlog 0
- eval_time: 18m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# ---------------------------------------------------------------------
# 9. THE WORST NIGHT ON RECORD, 2026-09-11, as raw 30s samples (see
# WORST_NIGHT in the generator). Peak 141, 26 samples above 5 = 12.5m
# of true expression. At `evaluation_interval: 1m` the rule sees it
# true from 02:13Z to 02:25Z: 12m pending against `for: 20m`,
# 8m of margin. This is the case that makes the margin
# load-bearing: at `for: 12m` it fires at 02:25Z, and nothing else in
# the suite notices (mutations.txt:backlog-for-12m).
# ---------------------------------------------------------------------
- name: worst night on record 2026-09-11 — peak 141, raw samples
interval: 30s
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# 02:08:00Z .. 02:47:00Z, one sample per 30s
values: '0 0 0 0 0 0 0 0 0 28 53 80 110 141 136 133 130 125 117 117 115 112 102 91 81 75 68 60 52 45 37 29 22 15 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
values: '148000 148007 148014 148021 148028 148035 148042 148049 148056 148063 148070 148077 148084 148091 148098 148105 148112 148119 148126 148133 148140 148147 148154 148161 148168 148175 148182 148189 148196 148203 148210 148217 148224 148231 148238 148245 148252 148259 148266 148273 148280 148287 148294 148301 148308 148315 148322 148329 148336 148343 148350 148357 148364 148371 148378 148385 148392 148399 148406 148413 148420 148427 148434 148441 148448 148455 148462 148469 148476 148483 148490 148497 148504 148511 148518 148525 148532 148539 148546'
alert_rule_test:
# 02:14Z, backlog 110
- eval_time: 6m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:25Z, backlog 8
- eval_time: 17m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# 02:47Z, backlog 0
- eval_time: 39m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# ---------------------------------------------------------------------
# 10. THE FAILURE THE RULE EXISTS FOR: an archiver that stopped with
# 70 segments (1.1 GiB) queued. Not a burst — it does not drain. The
# backlog crosses 5 at t=5m, so `for: 20m` elapses at t=25m.
# The assertions bracket the transition: one minute early nothing is
# firing, on the minute it is.
# ---------------------------------------------------------------------
- name: stalled archiver at 70 segments — must fire
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
# Flat, because a stalled archiver archives nothing. The rule does
# not read this; it is here so the input stays realistic.
values: '148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000 148000'
alert_rule_test:
- eval_time: 24m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 25m
alertname: CNPGWalArchiveBacklog
exp_alerts:
# No `role` label: proof that `max by (namespace, pod)` aggregated it away. See mutations.txt:no-max-by.
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 11. THE STALL A DEPTH THRESHOLD CANNOT SEE: 8 segments, stuck. It
# would never reach any number tuned above the nightly envelope, and
# point-in-time recovery is just as broken at 8 segments as at 70. This
# is why REN-152 keyed the rule on persistence and retired the depth rule.
# ---------------------------------------------------------------------
- name: low stall at 8 segments — must fire
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8'
alert_rule_test:
- eval_time: 24m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 25m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "8 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 12. PINNED AT EXACTLY 5 for the whole window. `> 5` is strict, so
# this is silence. A rule written `>= 5` would page on it, and a rule
# that had lost its `value="ready"` selector would page on the `done`
# counter regardless of what `ready` does — which is what the second
# series here is for.
# ---------------------------------------------------------------------
- name: pinned at exactly 5 — strict > means silence
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="done"}'
values: '148000 148014 148028 148042 148056 148070 148084 148098 148112 148126 148140 148154 148168 148182 148196 148210 148224 148238 148252 148266 148280 148294 148308 148322 148336 148350 148364 148378 148392 148406 148420 148434 148448 148462 148476 148490 148504 148518 148532 148546'
alert_rule_test:
- eval_time: 39m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
# ---------------------------------------------------------------------
# 13. FAILOVER DURING A STALL — the case `max by (namespace, pod)` was
# added for. The backlog starts at t=5m on the primary; at t=11m the
# instance is promoted and `role` changes, which is a NEW SERIES to
# Prometheus and restarts the `for` timer on the raw expression.
# Aggregating `role` away keeps one continuous series, so the page
# still lands 20m after the backlog started (t=25m) rather
# than 20m after the failover. Without `max by` this case is
# silent at t=25m — see mutations.txt:no-max-by.
# ---------------------------------------------------------------------
- name: role flip mid-stall — for timer must not restart
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
# t=0..10m, then this series ends.
values: '0 0 0 0 0 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="replica",value="ready"}'
# Starts at t=11m. `_` is a gap, not a zero.
values: '_ _ _ _ _ _ _ _ _ _ _ 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
alert_rule_test:
- eval_time: 24m
alertname: CNPGWalArchiveBacklog
exp_alerts: []
- eval_time: 25m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 14. ONE STALLED INSTANCE AMONG HEALTHY ONES. There are 12 CNPG
# instances in this cluster. `by (namespace, pod)` has to keep them
# apart: exactly one alert, naming the pod that is actually stuck, so
# the 04:00 reader is not sent to look at the wrong Postgres.
# ---------------------------------------------------------------------
- name: one stalled pod among healthy ones — exactly one alert
interval: 1m
input_series:
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70 70'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="home-assistant",pod="ha-pg-2",container="postgres",role="replica",value="ready"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- series: 'cnpg_collector_pg_wal_archive_status{namespace="immich",pod="immich-pg-1",container="postgres",role="primary",value="ready"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
alert_rule_test:
- eval_time: 25m
alertname: CNPGWalArchiveBacklog
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
pod: ha-pg-1
exp_annotations:
summary: "CNPG WAL archiving is falling behind on home-assistant/ha-pg-1"
description: "70 WAL segments have been waiting to archive for over 20 minutes. NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local) is normal and drains itself within about 13 minutes, and this rule is tuned to stay silent through it — so if this fired, the backlog did NOT clear and the archiver is stuck. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n home-assistant logs ha-pg-1 -c plugin-barman-cloud`."
# ---------------------------------------------------------------------
# 15. THE EPOCH-ZERO GUARD on CNPGBackupFailed. The metric is a unix
# timestamp and reads 0 when no failure was ever recorded, so the rule
# carries a `> 0` guard to stop that default being read as "failed in
# 1970". Held at 0 for the whole window: silence. Nothing else in this
# suite exercises that rule, and a dropped guard would page on every
# instance in the cluster forever.
# ---------------------------------------------------------------------
- name: never-failed backup reads timestamp 0 — must not fire
interval: 1m
input_series:
- series: 'cnpg_collector_last_failed_backup_timestamp{namespace="home-assistant",pod="ha-pg-1"}'
values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
alert_rule_test:
- eval_time: 39m
alertname: CNPGBackupFailed
exp_alerts: []
@@ -0,0 +1,115 @@
# promtool test rules for prometheusrule-external-secrets.yaml.
#
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
# scripts/ci/promtool-rules.sh, which strips `spec:` from the manifest into a
# mirrored tree — hence `../` below, resolved from this file's directory. See
# README.md here. The negative controls for this suite are the `eso-*` entries
# in mutations.txt.
#
# WHAT IT IS FOR. Every claim in the rule file that a reviewer would otherwise
# have to take on trust is asserted here instead: that the tombstone series does
# not alert, that `for: 15m` really is 15m, that the annotation names the
# ExternalSecret's own namespace and not the controller's, and that `max`
# survives a two-pod scrape where `sum` does not.
#
# THE REJECTED FORM, for the record. REN-100 and ESO's shipped dashboard both
# use `sum(...) by (namespace, name) == 1`. Run against the identical input
# series below, that expression produces exactly one alert,
# {namespace="external-secrets", name="gitea-db"} — the wrong namespace on the
# one it catches, and nothing at all for monitoring/loki-basic-auth, whose two
# scraped pods sum to 2 and stop matching `== 1`. Both defects, one expression.
rule_files:
- ../prometheusrule-external-secrets.yaml
evaluation_interval: 1m
tests:
# Series shapes copied from the live cluster after #994 landed: the metric's
# own `namespace` has already been shadowed to `exported_namespace` by the
# PodMonitor's target label, and `namespace` is the controller pod's.
- interval: 1m
input_series:
# A healthy ExternalSecret. ESO writes BOTH polarities of the Ready
# condition on every reconcile, the inactive one at 0 — which is why the
# rule needs `== 1` and not a bare selector.
- series: 'externalsecret_status_condition{condition="Ready",status="True",name="uptime-robot-api-key",exported_namespace="uptime-monitor",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '1+0x60'
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="uptime-robot-api-key",exported_namespace="uptime-monitor",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '0+0x60'
# Starts failing at t=10m and stays failed.
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="gitea-db",exported_namespace="gitea",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '0+0x9 1+0x50'
- series: 'externalsecret_status_condition{condition="Ready",status="True",name="gitea-db",exported_namespace="gitea",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '1+0x9 0+0x50'
# Tombstone left behind for an ExternalSecret that no longer exists. Held
# at 1 forever until the controller restarts, so a rule that matched
# `condition!="Ready"` or ignored `status` would page on it every day.
- series: 'externalsecret_status_condition{condition="Deleted",status="False",name="wiki-index-basic-auth",exported_namespace="ai",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '1+0x60'
# The same failing ExternalSecret reported by TWO controller pods, as
# happens for the seconds of a Deployment rollout when Prometheus still
# has the old target and already has the new one.
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="loki-basic-auth",exported_namespace="monitoring",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-56c649799b-t65gf"}'
values: '0+0x9 1+0x50'
- series: 'externalsecret_status_condition{condition="Ready",status="False",name="loki-basic-auth",exported_namespace="monitoring",namespace="external-secrets",job="monitoring/external-secrets",pod="external-secrets-7d4f9c6b88-qq2xv"}'
values: '0+0x9 1+0x50'
alert_rule_test:
# 5m into the failure: pending, not firing. Asserts the window is real —
# a `for:` accidentally set to 0 passes every other case here and fails
# only this one.
- eval_time: 15m
alertname: ExternalSecretSyncFailing
exp_alerts: []
# 20m into the failure. Both failing ExternalSecrets fire, ONCE each; the
# healthy one and the tombstone stay silent. The annotations are asserted
# in full because the whole point of REN-103's label finding is that the
# text names `gitea`, not `external-secrets`.
- eval_time: 30m
alertname: ExternalSecretSyncFailing
exp_alerts:
- exp_labels:
severity: warning
exported_namespace: gitea
name: gitea-db
exp_annotations:
summary: "ExternalSecret gitea/gitea-db is not syncing"
description: "ESO has been unable to sync this ExternalSecret for 15 minutes. The Secret it manages still holds its last successfully synced value, so consumers keep working — what breaks is that a rotation at the provider no longer propagates, which surfaces much later as a credential that stopped being accepted. Get the reason with `kubectl -n gitea get externalsecret gitea-db -o jsonpath='{.status.conditions}'`; the common causes are a key that was renamed or removed in OpenBao, a ClusterSecretStore that cannot authenticate, and a target Secret owned by something else."
- exp_labels:
severity: warning
exported_namespace: monitoring
name: loki-basic-auth
exp_annotations:
summary: "ExternalSecret monitoring/loki-basic-auth is not syncing"
description: "ESO has been unable to sync this ExternalSecret for 15 minutes. The Secret it manages still holds its last successfully synced value, so consumers keep working — what breaks is that a rotation at the provider no longer propagates, which surfaces much later as a credential that stopped being accepted. Get the reason with `kubectl -n monitoring get externalsecret loki-basic-auth -o jsonpath='{.status.conditions}'`; the common causes are a key that was renamed or removed in OpenBao, a ClusterSecretStore that cannot authenticate, and a target Secret owned by something else."
# Metrics are present, so the dead-man's-switch stays quiet.
- eval_time: 30m
alertname: ExternalSecretMetricsMissing
exp_alerts: []
# The scrape disappears entirely: PodMonitor deleted, selector broken by a
# chart bump, or the `monitoring` ingress grant reverted out of
# external-secrets-ns.yaml. ExternalSecretSyncFailing cannot fire on series
# that do not exist — this is the case that made REN-103 necessary, so it is
# the one the dead-man's-switch has to cover.
- interval: 1m
input_series:
- series: 'up{job="monitoring/external-secrets"}'
values: '0+0x60'
alert_rule_test:
- eval_time: 30m
alertname: ExternalSecretSyncFailing
exp_alerts: []
- eval_time: 30m
alertname: ExternalSecretMetricsMissing
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "No ExternalSecret metrics are reaching Prometheus"
description: "`externalsecret_status_condition` has no series. Either the ESO controller is gone or its scrape stopped being collected, and ExternalSecretSyncFailing is blind for as long as this is true. Check the PodMonitor `monitoring/external-secrets` still selects the controller pods, and that external-secrets-ns.yaml still admits `monitoring` on TCP/8080."
@@ -0,0 +1,555 @@
#!/usr/bin/env python3
"""Regenerate cnpg-backup_test.yaml from the REN-129 measurements.
The committed YAML is what CI runs; this script exists so the numbers in it are
auditable and reproducible from the measurement table rather than being a
hand-typed artifact nobody can re-derive. Run it from the repo root:
python3 kubernetes/apps/monitoring-rules/tests/generate-cnpg-backup-test.py \
> kubernetes/apps/monitoring-rules/tests/cnpg-backup_test.yaml
Nothing in CI regenerates and diffs this — the promtool image has no Python, and
the defence against a hand-weakened suite is the mutation phase of
scripts/ci/promtool-rules.sh, not byte-equality with a generator. See README.md.
THE TRACE MODEL. Iris measured seven consecutive nights of
`cnpg_collector_pg_wal_archive_status{value="ready",pod="ha-pg-1"}` at 1-minute
steps and published, per night, a (peak, peak minute, fill seg/min, drain
seg/min) tuple. Those four numbers are consistent with one shape, which is the
shape every night has: flat 0, a linear ramp at `fill` per minute to `peak`,
then a linear decay at `drain` per minute back to 0. So a night is rebuilt as
ramp length = round(peak / fill) minutes
ramp start = peak minute - ramp length (value 0 at that minute)
during ramp = round(fill * minutes since ramp start)
at peak = peak (exact, not rounded from fill)
during decay = max(0, round(peak - drain * minutes since peak))
FIDELITY. The reconstruction reproduces Iris's published `Duration >5` column
exactly on 2026-09-20, -21 and -22, and runs one minute long on -23 and -24 and
two minutes long on -25 and -26. Every discrepancy is in the same direction:
the rebuild is never SHORTER above the threshold than the measurement. A linear
decay at the published mean drain rate holds the tail above 5 for slightly
longer than the real trace did, so each night is a marginally harder silence
test than the night it stands for. That is the safe direction for this suite,
whose whole job is to catch a rule that fires on a benign night. `--report`
prints the comparison; do not "fix" it toward the published column without
checking which way each night moves.
These are reconstructions, not the raw samples. They are faithful in the only
dimensions the rules read: how high the backlog goes, and how many consecutive
minutes it spends above each threshold. Do not read them as a recording.
"""
import argparse
import sys
# Iris's 7-night measurement, REN-129. `dur_gt5` is her published "Duration >5"
# column and is an INPUT TO THE SELF-CHECK ONLY — the traces are built from
# peak/fill/drain/peak_min, and dur_gt5 is what the result is compared against.
NIGHTS = [
# date peak peak_min fill drain dur_gt5
("2026-09-20", 106, "02:15", 35.3, 13.2, 10),
("2026-09-21", 100, "02:15", 33.3, 14.3, 9),
("2026-09-22", 101, "02:15", 33.7, 14.4, 9),
("2026-09-23", 119, "02:15", 39.7, 14.9, 9),
("2026-09-24", 56, "02:14", 18.7, 14.0, 5),
("2026-09-25", 91, "02:15", 22.8, 13.0, 8),
("2026-09-26", 113, "02:15", 28.5, 14.1, 9),
]
# The adversarial envelope: worst fill, worst peak, slowest drain observed in the
# week, combined into a night that did not happen but is inside what the cluster
# has demonstrated it can do. Nothing here is extrapolated past the measurements.
ENVELOPE = ("envelope", 119, "02:15", 39.7, 13.0, None)
# The worst night in Prometheus' 27-night retention (2026-08-30..09-26):
# 2026-09-11, peak 141, the longest excursion above 5 on record. Not rebuilt
# from a model like NIGHTS — these are the raw samples, read back through the
# API server's services/proxy with
# max by (namespace, pod) (cnpg_collector_pg_wal_archive_status{
# value="ready", pod="ha-pg-1"})
# over 02:08:00Z..02:47:00Z at the real 30s scrape step (REN-152). 26
# consecutive samples above 5 = 12.5 minutes pending, which is the number the
# `for: 20m` margin is measured against, and why mutations.txt:backlog-for-12m
# is caught here and nowhere else.
WORST_NIGHT = ("2026-09-11", [
0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 53, 80, 110, 141, 136, 133, 130, 125, 117,
117, 115, 112, 102, 91, 81, 75, 68, 60, 52, 45, 37, 29, 22, 15, 8, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
])
FOR_MIN = 20 # `for:` on CNPGWalArchiveBacklog, in minutes
# Series labels. `container`, `role` and `instance` are on the real series and
# are carried here on purpose: they are what `max by (namespace, pod)` has to
# aggregate away, and a test whose input is already aggregated cannot see whether
# the rule does that. `value="done"` is the cumulative counter that sits on the
# same metric name — it is in the input so that dropping the `value="ready"`
# selector from the rules is a detectable mutation and not a silent no-op.
NS = "home-assistant"
POD = "ha-pg-1"
def labels(pod=POD, value="ready", role="primary", ns=NS):
return (
"cnpg_collector_pg_wal_archive_status{"
f'namespace="{ns}",pod="{pod}",container="postgres",'
f'role="{role}",value="{value}"'
"}"
)
# Test window: t=0 is 02:08Z, 40 one-minute samples, so every night's burst sits
# inside it with lead-in and tail. promtool eval_times are relative to t=0, so
# every assertion below carries its UTC clock time in a comment.
#
# This MUST stay aligned with the peak minutes in NIGHTS. An earlier revision
# had `8 * 60 + 8` here (08:08Z), which put the whole window seven hours after
# every burst: all seven nights rebuilt as 40 zeroes and their ~90 silence
# assertions passed without exercising a single threshold. The
# assert_window_covers_bursts() call in main() exists so that cannot recur
# silently.
T0 = 2 * 60 + 8 # 02:08Z in minutes since midnight
SAMPLES = 40
def hhmm(minute):
return f"{minute // 60:02d}:{minute % 60:02d}"
def parse_hhmm(s):
h, m = s.split(":")
return int(h) * 60 + int(m)
def trace(peak, peak_min, fill, drain, n=SAMPLES, t0=T0):
"""Rebuild one night. See THE TRACE MODEL in the module docstring."""
ramp_len = max(1, round(peak / fill))
ramp_start = peak_min - ramp_len
out = []
for m in range(t0, t0 + n):
if m <= ramp_start:
v = 0
elif m < peak_min:
v = round(fill * (m - ramp_start))
elif m == peak_min:
v = peak
else:
v = max(0, round(peak - drain * (m - peak_min)))
out.append(v)
return out
def longest_run_above(vals, thr):
"""(length, index of last sample) of the longest run of vals > thr."""
best = (0, None)
run = 0
for i, v in enumerate(vals):
if v > thr:
run += 1
if run > best[0]:
best = (run, i)
else:
run = 0
return best
def pending_minutes(vals, thr):
"""Max minutes a `for` timer accumulates on `> thr` over this trace.
This is NOT the run length, and the difference is the whole margin
argument. Prometheus makes an alert pending at the first evaluation where
the expression is true and fires it when `now - activeAt >= for`, so a run
of N consecutive 1-minute samples above the threshold accumulates N-1
minutes of pending time, not N. A trace that spends 5 samples above 64
therefore does NOT fire `for: 5m`; it fires `for: 4m`. Quote this number,
not the run length, when arguing about a `for` window.
"""
n, _ = longest_run_above(vals, thr)
return max(0, n - 1)
def assert_window_covers_bursts():
"""Refuse to emit a suite whose trace window misses the bursts.
Guards the T0 bug described above T0: if the window and the peak minutes
ever drift apart again, every measured night rebuilds as flat zero and the
suite passes while testing nothing. A night that is flat 0 cannot be a
silence test for a burst, so treat it as a generator fault, not a result.
"""
for date, peak, pm, fill, drain, _dur in NIGHTS + [ENVELOPE]:
vals = trace(peak, parse_hhmm(pm), fill, drain)
if max(vals) != peak:
raise SystemExit(
f"generator fault: {date} rebuilt with max {max(vals)}, expected "
f"peak {peak}. The T0={T0} window ({hhmm(T0)}Z.."
f"{hhmm(T0 + SAMPLES - 1)}Z) does not cover peak minute {pm}Z. "
"Fix T0 or SAMPLES; do not emit a suite of flat-zero nights."
)
def counter(start, step, n=SAMPLES):
return [start + step * i for i in range(n)]
def fmt(vals):
return " ".join(str(v) for v in vals)
def eval_minutes(vals):
"""The minutes where a `for` window is closest to elapsing, so the tightest
places to assert silence. For each threshold: the last sample of the longest
run above it (the moment maximum pending time has accumulated). Plus the
peak, plus two minutes after the backlog has settled back to 0."""
picks = set()
_, last = longest_run_above(vals, 5)
if last is not None:
picks.add(last)
picks.add(max(range(len(vals)), key=lambda i: vals[i]))
settled = next(
(i for i in range(len(vals) - 1, -1, -1) if vals[i] > 0),
len(vals) - 1,
)
picks.add(min(settled + 2, len(vals) - 1))
return sorted(picks)
L = []
def w(line=""):
L.append(line)
def burst_case(name, date, peak, peak_min_s, fill, drain, why):
peak_min = parse_hhmm(peak_min_s)
vals = trace(peak, peak_min, fill, drain)
n5, _ = longest_run_above(vals, 5)
w(f" # {why}")
w(
f" # peak {peak} at {peak_min_s}Z, fill {fill}/min, drain {drain}/min"
f" -> {n5} samples above 5 ({pending_minutes(vals, 5)}m pending vs"
f" for: {FOR_MIN}m)."
)
w(f" - name: {name}")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" # {hhmm(T0)}Z .. {hhmm(T0 + SAMPLES - 1)}Z, one sample per minute")
w(f" values: '{fmt(vals)}'")
w(f" - series: '{labels(value='done')}'")
w(" # The cumulative archived counter on the same metric name. The")
w(" # rules must never read it; see mutations.txt:no-value-selector.")
w(f" values: '{fmt(counter(148_000, 14))}'")
w(" alert_rule_test:")
for i in eval_minutes(vals):
clock = hhmm(T0 + i)
w(f" # {clock}Z, backlog {vals[i]}")
w(f" - eval_time: {i}m")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts: []")
w("")
return vals, n5
def desc(n, pod=POD, ns=NS):
"""The rule's `description` as promtool renders it for `n` segments."""
return (
f"{n} WAL segments have been waiting to archive for over {FOR_MIN} minutes."
" NOTE ON TIMING: a nightly write burst on ha-pg-1 at 02:14Z (04:14 local)"
" is normal and drains itself within about 13 minutes, and this rule is"
" tuned to stay silent through it — so if this fired, the backlog did NOT"
" clear and the archiver is stuck. Point-in-time recovery is broken for"
" anything after the last archived segment, and pg_wal will grow until the"
" PVC fills. Check the barman-cloud sidecar logs and the ObjectStore"
f" credentials: `kubectl -n {ns} logs {pod} -c plugin-barman-cloud`."
)
def firing(eval_time, n, pod=POD, ns=NS, note=None):
w(f" - eval_time: {eval_time}")
w(" alertname: CNPGWalArchiveBacklog")
w(" exp_alerts:")
if note:
w(f" # {note}")
w(" - exp_labels:")
w(" severity: warning")
w(f" namespace: {ns}")
w(f" pod: {pod}")
w(" exp_annotations:")
w(f' summary: "CNPG WAL archiving is falling behind on {ns}/{pod}"')
w(f' description: "{desc(n, pod, ns)}"')
def silent(eval_time, alert="CNPGWalArchiveBacklog"):
w(f" - eval_time: {eval_time}")
w(f" alertname: {alert}")
w(" exp_alerts: []")
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--report",
action="store_true",
help="print the reconstruction self-check table instead of the suite",
)
args = ap.parse_args()
assert_window_covers_bursts()
if args.report:
# `pend` is what the `for` window is actually compared against; see
# pending_minutes(). `>5 pub` is Iris's published column and is the
# only number here that is not derived by this script.
print(f"{'night':12} {'peak':>5} {'fill':>6} {'drain':>6} "
f"{'>5 pub':>7} {'>5 n':>6} {'>5 pend':>8}")
for date, peak, pm, fill, drain, dur in NIGHTS + [ENVELOPE]:
vals = trace(peak, parse_hhmm(pm), fill, drain)
n5, _ = longest_run_above(vals, 5)
print(f"{date:12} {peak:5} {fill:6} {drain:6} "
f"{('-' if dur is None else dur):>7} {n5:>6} "
f"{pending_minutes(vals, 5):>8}")
wn, _ = longest_run_above(WORST_NIGHT[1], 5)
print(f"{WORST_NIGHT[0]:12} {max(WORST_NIGHT[1]):5} {'raw':>6} {'30s':>6} "
f"{'-':>7} {wn:>6} {max(0, wn - 1) / 2:>8}")
print()
print(f"for: {FOR_MIN}m on >5 must exceed every '>5 pend' above.")
return 0
w("# GENERATED by generate-cnpg-backup-test.py — see README.md in this")
w("# directory before editing by hand.")
w("#")
w("# Unit tests for kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml.")
w("# Every assertion below is either one of Iris's seven measured nights")
w("# (REN-129) rebuilt from its published (peak, fill, drain) numbers, the")
w("# worst night in Prometheus' retention as raw samples (REN-152), an")
w("# adversarial combination of the extremes of those numbers, or a failure")
w("# this cluster has not had but the rule exists to catch.")
w("#")
w("# `rule_files` entries are resolved RELATIVE TO THIS FILE'S DIRECTORY, not")
w("# to the working directory promtool was invoked from — promtool joins")
w("# dirname(testfile) onto each entry. So this is `../`, and it resolves in")
w("# the mirrored tree scripts/ci/promtool-rules.sh builds, where the")
w("# `spec:`-stripped manifests sit in the same layout as the real ones.")
w("#")
w("# A path that does NOT resolve is only a WARNING to promtool: it loads zero")
w("# rules, every exp_alerts: [] assertion passes against nothing, and the")
w("# suite exits 0. promtool-rules.sh resolves every entry itself before")
w("# running and fails on a miss; that check is the only thing standing")
w("# between a typo here and a green pipeline that tests nothing.")
w("rule_files:")
w(" - ../prometheusrule-cnpg-backup.yaml")
w("evaluation_interval: 1m")
w("")
w("tests:")
w(" # ---------------------------------------------------------------------")
w(" # 1-7. THE SEVEN MEASURED NIGHTS. The rule must stay silent through")
w(" # every one of them. The retired depth rule (`> 64`) shipped for review")
w(" # at `for: 2m` and fired on 6 of these 7 nights; REN-152 removed it.")
w(" # ---------------------------------------------------------------------")
checks = []
for date, peak, pm, fill, drain, dur in NIGHTS:
vals, n5 = burst_case(
f"nightly burst {date} — recorder purge, benign, self-clearing",
date, peak, pm, fill, drain,
f"{date}: the nightly Home Assistant recorder purge at 04:12 local.",
)
checks.append((date, dur, n5))
w(" # ---------------------------------------------------------------------")
w(" # 8. THE ADVERSARIAL ENVELOPE. A night that did not happen, built from")
w(" # the worst fill (39.7/min, 09-23), the highest peak (119, 09-23) and")
w(" # the slowest drain (13.0/min, 09-25) in the week. 11 samples above 5,")
w(" # 10m pending against `for: 20m`.")
w(" # ---------------------------------------------------------------------")
burst_case(
"adversarial envelope — worst measured fill and peak, slowest drain",
*ENVELOPE[:5],
why="Synthetic: max fill 39.7 + max peak 119 + min drain 13.0.",
)
date, raw = WORST_NIGHT
wn, wlast = longest_run_above(raw, 5)
peak_i = max(range(len(raw)), key=lambda i: raw[i])
w(" # ---------------------------------------------------------------------")
w(f" # 9. THE WORST NIGHT ON RECORD, {date}, as raw 30s samples (see")
w(" # WORST_NIGHT in the generator). Peak 141, 26 samples above 5 = 12.5m")
w(" # of true expression. At `evaluation_interval: 1m` the rule sees it")
w(f" # true from 02:13Z to 02:25Z: 12m pending against `for: {FOR_MIN}m`,")
w(f" # {FOR_MIN - 12}m of margin. This is the case that makes the margin")
w(" # load-bearing: at `for: 12m` it fires at 02:25Z, and nothing else in")
w(" # the suite notices (mutations.txt:backlog-for-12m).")
w(" # ---------------------------------------------------------------------")
w(f" - name: worst night on record {date} — peak 141, raw samples")
w(" interval: 30s")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" # {hhmm(T0)}:00Z .. {hhmm(T0 + 39)}:00Z, one sample per 30s")
w(f" values: '{fmt(raw)}'")
w(f" - series: '{labels(value='done')}'")
w(f" values: '{fmt(counter(148_000, 7, len(raw)))}'")
w(" alert_rule_test:")
for i in (peak_i, wlast, len(raw) - 1):
# eval times in whole minutes: the group evaluates on the minute.
m = (i // 2)
w(f" # {hhmm(T0 + m)}Z, backlog {raw[m * 2]}")
silent(f"{m}m")
w("")
# ---- 10. stalled archiver, well above threshold ------------------------
stalled = [0] * 5 + [70] * 35
fire_at = 5 + FOR_MIN
w(" # ---------------------------------------------------------------------")
w(" # 10. THE FAILURE THE RULE EXISTS FOR: an archiver that stopped with")
w(" # 70 segments (1.1 GiB) queued. Not a burst — it does not drain. The")
w(f" # backlog crosses 5 at t=5m, so `for: {FOR_MIN}m` elapses at t={fire_at}m.")
w(" # The assertions bracket the transition: one minute early nothing is")
w(" # firing, on the minute it is.")
w(" # ---------------------------------------------------------------------")
w(" - name: stalled archiver at 70 segments — must fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt(stalled)}'")
w(f" - series: '{labels(value='done')}'")
w(" # Flat, because a stalled archiver archives nothing. The rule does")
w(" # not read this; it is here so the input stays realistic.")
w(f" values: '{fmt([148_000] * SAMPLES)}'")
w(" alert_rule_test:")
silent(f"{fire_at - 1}m")
firing(f"{fire_at}m", 70, note="No `role` label: proof that `max by (namespace, pod)` "
"aggregated it away. See mutations.txt:no-max-by.")
w("")
# ---- 11. low stall ------------------------------------------------------
low = [0] * 5 + [8] * 35
w(" # ---------------------------------------------------------------------")
w(" # 11. THE STALL A DEPTH THRESHOLD CANNOT SEE: 8 segments, stuck. It")
w(" # would never reach any number tuned above the nightly envelope, and")
w(" # point-in-time recovery is just as broken at 8 segments as at 70. This")
w(" # is why REN-152 keyed the rule on persistence and retired the depth rule.")
w(" # ---------------------------------------------------------------------")
w(" - name: low stall at 8 segments — must fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt(low)}'")
w(" alert_rule_test:")
silent(f"{fire_at - 1}m")
firing(f"{fire_at}m", 8)
w("")
# ---- 12. exactly at the threshold ------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 12. PINNED AT EXACTLY 5 for the whole window. `> 5` is strict, so")
w(" # this is silence. A rule written `>= 5` would page on it, and a rule")
w(" # that had lost its `value=\"ready\"` selector would page on the `done`")
w(" # counter regardless of what `ready` does — which is what the second")
w(" # series here is for.")
w(" # ---------------------------------------------------------------------")
w(" - name: pinned at exactly 5 — strict > means silence")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels()}'")
w(f" values: '{fmt([5] * SAMPLES)}'")
w(f" - series: '{labels(value='done')}'")
w(f" values: '{fmt(counter(148_000, 14))}'")
w(" alert_rule_test:")
silent("39m")
w("")
# ---- 13. role flip ---------------------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 13. FAILOVER DURING A STALL — the case `max by (namespace, pod)` was")
w(" # added for. The backlog starts at t=5m on the primary; at t=11m the")
w(" # instance is promoted and `role` changes, which is a NEW SERIES to")
w(" # Prometheus and restarts the `for` timer on the raw expression.")
w(" # Aggregating `role` away keeps one continuous series, so the page")
w(f" # still lands {FOR_MIN}m after the backlog started (t={fire_at}m) rather")
w(f" # than {FOR_MIN}m after the failover. Without `max by` this case is")
w(f" # silent at t={fire_at}m — see mutations.txt:no-max-by.")
w(" # ---------------------------------------------------------------------")
w(" - name: role flip mid-stall — for timer must not restart")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels(role='primary')}'")
w(" # t=0..10m, then this series ends.")
w(f" values: '{fmt([0] * 5 + [70] * 6)}'")
w(f" - series: '{labels(role='replica')}'")
w(" # Starts at t=11m. `_` is a gap, not a zero.")
w(f" values: '{'_ ' * 11}{fmt([70] * 29)}'")
w(" alert_rule_test:")
silent(f"{fire_at - 1}m")
firing(f"{fire_at}m", 70)
w("")
# ---- 14. one stalled pod among healthy ones --------------------------
w(" # ---------------------------------------------------------------------")
w(" # 14. ONE STALLED INSTANCE AMONG HEALTHY ONES. There are 12 CNPG")
w(" # instances in this cluster. `by (namespace, pod)` has to keep them")
w(" # apart: exactly one alert, naming the pod that is actually stuck, so")
w(" # the 04:00 reader is not sent to look at the wrong Postgres.")
w(" # ---------------------------------------------------------------------")
w(" - name: one stalled pod among healthy ones — exactly one alert")
w(" interval: 1m")
w(" input_series:")
w(f" - series: '{labels(pod='ha-pg-1')}'")
w(f" values: '{fmt([0] * 5 + [70] * 35)}'")
w(f" - series: '{labels(pod='ha-pg-2', role='replica')}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(f" - series: '{labels(pod='immich-pg-1', ns='immich')}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(" alert_rule_test:")
firing(f"{fire_at}m", 70, pod="ha-pg-1")
w("")
# ---- 15. CNPGBackupFailed --------------------------------------------
w(" # ---------------------------------------------------------------------")
w(" # 15. THE EPOCH-ZERO GUARD on CNPGBackupFailed. The metric is a unix")
w(" # timestamp and reads 0 when no failure was ever recorded, so the rule")
w(" # carries a `> 0` guard to stop that default being read as \"failed in")
w(" # 1970\". Held at 0 for the whole window: silence. Nothing else in this")
w(" # suite exercises that rule, and a dropped guard would page on every")
w(" # instance in the cluster forever.")
w(" # ---------------------------------------------------------------------")
w(" - name: never-failed backup reads timestamp 0 — must not fire")
w(" interval: 1m")
w(" input_series:")
w(f" - series: 'cnpg_collector_last_failed_backup_timestamp{{namespace=\"{NS}\",pod=\"{POD}\"}}'")
w(f" values: '{fmt([0] * SAMPLES)}'")
w(" alert_rule_test:")
silent("39m", "CNPGBackupFailed")
print("\n".join(L))
# Fidelity self-check on stderr so it is visible when regenerating but does
# not contaminate the suite on stdout. Longer than measured is tolerated and
# expected (see FIDELITY in the module docstring); SHORTER is not, because a
# night that clears the threshold sooner than the real one is a weaker
# silence test than the night it claims to stand for.
short = [(d, m, r) for d, m, r in checks if r < m]
if short:
print(
"FAIL: reconstruction is SHORTER above 5 than the measurement on "
+ ", ".join(f"{d} measured {m} rebuilt {r}" for d, m, r in short)
+ " — these nights are weaker silence tests than the real ones. "
"Refusing to vouch for the suite.",
file=sys.stderr,
)
return 1
long_ = [(d, m, r) for d, m, r in checks if r > m]
if long_:
print(
f"NOTE: {len(long_)} of {len(checks)} nights rebuild LONGER above 5 "
"than the published column (conservative direction): "
+ ", ".join(f"{d} {m}->{r}" for d, m, r in long_),
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,223 @@
# promtool test rules for prometheusrule-home-assistant.yaml (REN-245).
#
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
# scripts/ci/promtool-rules.sh — see README.md here. Negative controls are the
# `ha-*` entries in mutations.txt.
#
# WHAT IT IS FOR. Each rule claims three things in its comments: it fires 5m
# after the outage starts and not before, it stays silent on a healthy system,
# and one outage is one alert even when the outage changes shape. Each claim
# is asserted here. Series names and labels follow the live shapes recorded in
# the REN-244 "findings" document.
rule_files:
- ../prometheusrule-home-assistant.yaml
evaluation_interval: 1m
tests:
# --- HomeAssistantDown --------------------------------------------------
# Target answers for 10m, fails for 5m, disappears (Recreate rollout) for
# 3m, then comes back still failing. One outage from t=10m: the alert must
# fire at t=15m and still be the same alert after the target changed shape.
- interval: 1m
input_series:
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124",pod="home-assistant-6d9c7b5f4-abcde"}'
values: '1x9 0x4'
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.2:8124",pod="home-assistant-6d9c7b5f4-fghij"}'
values: '_x22 0x10'
alert_rule_test:
- eval_time: 14m
alertname: HomeAssistantDown
exp_alerts: []
- eval_time: 15m
alertname: HomeAssistantDown
exp_alerts:
- exp_labels: {severity: critical, job: home-assistant, namespace: home-assistant}
exp_annotations:
summary: "Home Assistant has been down for 5 minutes"
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
# Old target gone (stale after 5m lookback), new one failing: same alert.
- eval_time: 25m
alertname: HomeAssistantDown
exp_alerts:
- exp_labels: {severity: critical, job: home-assistant, namespace: home-assistant}
exp_annotations:
summary: "Home Assistant has been down for 5 minutes"
description: "The home-assistant scrape target in namespace home-assistant is down or missing. A restart after an automatic image upgrade can take up to 10 minutes, so if an upgrade just rolled out, give it until then before acting. Rollback: kubernetes/RUNBOOK-home-assistant-image-rollback.md."
# Healthy HA stays silent.
- interval: 1m
input_series:
- series: 'up{job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124",pod="home-assistant-6d9c7b5f4-abcde"}'
values: '1x30'
alert_rule_test:
- eval_time: 30m
alertname: HomeAssistantDown
exp_alerts: []
# --- HomeAssistantRecorderDBUnhealthy ----------------------------------
# Two instances as in ha-pg-cluster.yaml. The replica's Postgres goes down at
# t=10m (its exporter keeps answering with cnpg_collector_up=0).
- interval: 1m
input_series:
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
values: '1x30'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
values: '1x9 0x20'
alert_rule_test:
- eval_time: 9m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts: []
- eval_time: 14m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts: []
- eval_time: 15m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Recorder database ha-pg has no primary or is missing an instance"
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
# Primary's Postgres goes down at t=10m. Both branches are true at once (no
# primary AND only one instance up); still exactly one alert.
- interval: 1m
input_series:
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
values: '1x9 0x20'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
values: '1x30'
alert_rule_test:
- eval_time: 15m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Recorder database ha-pg has no primary or is missing an instance"
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
# Both instances up but neither is primary (failover stuck): fires.
- interval: 1m
input_series:
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="replica",cluster="ha-pg"}'
values: '1x30'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
values: '1x30'
alert_rule_test:
- eval_time: 4m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts: []
- eval_time: 5m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Recorder database ha-pg has no primary or is missing an instance"
description: "In CNPG cluster home-assistant/ha-pg either no instance reports role=primary with Postgres up, or fewer than 2 instances have Postgres up. Without a primary the Home Assistant recorder cannot write history. Check `kubectl -n home-assistant get cluster ha-pg` and the pods' logs."
# Clean switchover at t=10m: ha-pg-1 takes primary, ha-pg-2 becomes replica.
# The role label flips, so both old series go stale and new ones start. Must
# stay silent throughout.
- interval: 1m
input_series:
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="primary",cluster="ha-pg"}'
values: '1x9'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="replica",cluster="ha-pg"}'
values: '1x9'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-1",role="primary",cluster="ha-pg"}'
values: '_x10 1x20'
- series: 'cnpg_collector_up{job="home-assistant/cnpg-ha-pg",namespace="home-assistant",pod="ha-pg-2",role="replica",cluster="ha-pg"}'
values: '_x10 1x20'
alert_rule_test:
- eval_time: 12m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts: []
- eval_time: 30m
alertname: HomeAssistantRecorderDBUnhealthy
exp_alerts: []
# --- MosquittoDown ------------------------------------------------------
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
# Ready. The Job pod must not mask the broker.
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
values: '1x9 0x20'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
values: '1x30'
alert_rule_test:
- eval_time: 14m
alertname: MosquittoDown
exp_alerts: []
- eval_time: 15m
alertname: MosquittoDown
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Mosquitto (MQTT broker) has not been ready for 5 minutes"
description: "No mosquitto pod in namespace home-assistant is Ready. Zigbee2MQTT, Shelly and every other MQTT device lose their link to Home Assistant while this lasts, so Zigbee2MQTTDownOrCoordinatorOffline may fire alongside it for the same cause."
# --- Zigbee2MQTTDownOrCoordinatorOffline -------------------------------
# Pod Ready throughout; HA's bridge sensor reads 0 from t=10m (coordinator
# unplugged).
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
values: '1x30'
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
values: '1x9 0x20'
alert_rule_test:
- eval_time: 14m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts: []
- eval_time: 15m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
# Pod not Ready from t=10m with the bridge sensor at 0; a replacement pod is
# Ready from t=18m but the bridge stays offline. One outage that changes
# shape: fires at t=15m as one alert, and is still that alert once only the
# sensor branch is true.
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
values: '1x9 0x20'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-r5s6t",condition="true"}'
values: '_x17 1x12'
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
values: '1x9 0x20'
alert_rule_test:
- eval_time: 14m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts: []
- eval_time: 15m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
- eval_time: 20m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts:
- exp_labels: {severity: critical, namespace: home-assistant}
exp_annotations:
summary: "Zigbee2MQTT is down or its coordinator/bridge is offline"
description: "Either the zigbee2mqtt pod in namespace home-assistant is not Ready, or Home Assistant's binary_sensor.zigbee2mqtt_bridge_connection_state reads off. If MosquittoDown is firing too, fix the broker first; the bridge sensor goes off whenever MQTT is gone."
# HA down: the bridge sensor goes stale and disappears, the Z2M pod is fine.
# HomeAssistantDown owns this; the Zigbee rule must stay silent.
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="zigbee2mqtt-5c4d8f7b6-m3n4p",condition="true"}'
values: '1x30'
- series: 'homeassistant_binary_sensor_state{entity="binary_sensor.zigbee2mqtt_bridge_connection_state",domain="binary_sensor",friendly_name="Zigbee2MQTT Bridge Connection state",job="home-assistant",namespace="home-assistant",instance="10.0.0.1:8124"}'
values: '1x9'
alert_rule_test:
- eval_time: 30m
alertname: Zigbee2MQTTDownOrCoordinatorOffline
exp_alerts: []
@@ -0,0 +1,151 @@
# Negative controls for the promtool suites in this directory.
#
# A unit-test suite that passes proves the rules behave as asserted. It does not
# prove the assertions are worth anything: a suite whose `rule_files` path does
# not resolve, or whose traces are all zero, passes just as green. Both of those
# happened while this suite was being written, and neither was visible from the
# exit code.
#
# So scripts/ci/promtool-rules.sh runs a second phase. For each mutation below
# it damages ONE value the rules depend on, re-runs every suite, and requires the
# run to FAIL. A mutation the suite does not notice is a hole in the suite, and
# CI reports it as such.
#
# FORMAT — three `|`-separated fields, no `|` inside any of them:
#
# name | manifest path relative to the repo root | sed program
#
# The sed program is applied to the `spec:`-stripped copy in the mirrored tree,
# so its indentation is two spaces shallower than the manifest on disk. Anchor
# on `^[[:space:]]*` rather than a fixed indent. `#` in the first column is a
# comment; blank lines are ignored.
#
# The script also requires each sed program to CHANGE the file. A mutation that
# matches nothing is reported as a failure, not skipped — otherwise renaming a
# rule or rewording an expression silently turns its control into a no-op and
# the suite goes back to vouching for itself.
#
# `detected by` notes below are for the reader. The machine check is only "the
# suite must fail"; nothing parses them.
# The margin control, and the only reason case 9 is in the suite. The worst
# night in Prometheus' retention (2026-09-11, peak 141) holds the expression
# true for 12 minutes at a 1m evaluation step, so at `for: 12m` it fires. The
# seven reconstructed nights peak at 10m pending and would stay silent — without
# the raw worst-night case, `for: 20m` could be cut to 12m with the suite green.
# Detected by: case 9, the worst night on record.
backlog-for-12m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 20m$/\1for: 12m/
# The value REN-129 changed, in the direction it changed it from. At 30m the
# rule does not page until 35 minutes into a stall that started at t=5m, so the
# assertion at t=25m sees nothing. This is the control that stops the 20m being
# quietly walked back.
# Detected by: cases 10, 11, 13, 14 — every case that expects a page.
backlog-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|s/^\([[:space:]]*\)for: 20m$/\1for: 30m/
# Drop the aggregation, keeping the selector. `role` flips on failover; a
# changed label is a new series to Prometheus and restarts the `for` timer, so
# a backlog that persists across a promotion pages 20m after the promotion
# instead of 20m after the backlog. Scoped to `expr:` lines because
# `max by (namespace, pod)` also appears in this manifest's comments, where
# rewriting it would mutate the documentation and nothing else.
# Detected by: case 13, the role flip.
no-max-by|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/max by (namespace, pod) (\(cnpg_collector_pg_wal_archive_status{value="ready"}\))/\1/
# Drop the `value="ready"` selector. The same metric name also carries
# `value="done"`, a monotonic counter in the hundreds of thousands, so the
# rule would fire permanently on every CNPG instance in the cluster. The
# measured-night cases carry that counter series in their input specifically so
# this is detectable rather than a silent no-op.
# Detected by: cases 1-9 and 12 — every case with a `done` series.
no-value-selector|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/{value="ready"}//
# Loosen the comparison. `> 5` is strict; `>= 5` pages
# on a backlog sitting at exactly five segments, which is 80 MiB and normal.
# Detected by: case 12, pinned at exactly 5.
backlog-ge-5|kubernetes/apps/monitoring-rules/prometheusrule-cnpg-backup.yaml|/^[[:space:]]*expr:/s/> 5$/>= 5/
# --- prometheusrule-external-secrets.yaml (REN-103) -------------------------
# Scoped to the ExternalSecretSyncFailing expression with /max by/ or /== 1/
# where it matters: the manifest's comments quote the rejected forms, and
# rewriting those would mutate the documentation and nothing else.
# The window. At 5m the failure that starts at t=10m is already firing at the
# t=15m assertion, which expects pending.
# Detected by: the eval_time 15m case.
eso-for-5m|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)for: 15m$/\1for: 5m/
# REN-100's aggregation. `sum` over the two-pod rollout case is 2, fails
# `== 1`, and loki-basic-auth never fires.
# Detected by: the eval_time 30m case, loki-basic-auth.
eso-sum-not-max|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)max by (exported_namespace, name) ($/\1sum by (exported_namespace, name) (/
# The label Argus found shadowed on #994. Grouping by `namespace` names the
# controller's namespace on every alert.
# Detected by: the eval_time 30m case, exp_labels and the rendered summary.
eso-by-namespace|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)max by (exported_namespace, name) ($/\1max by (namespace, name) (/
# Drop `== 1`. ESO writes the inactive Ready polarity at 0, so every healthy
# ExternalSecret matches the bare selector and fires.
# Detected by: the eval_time 30m case, uptime-robot-api-key.
eso-no-eq-1|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)) == 1$/\1)/
# Drop the condition selector. The Deleted tombstone is status="False" at 1
# forever, so it pages until the controller restarts.
# Detected by: the eval_time 30m case, wiki-index-basic-auth.
eso-any-condition|kubernetes/apps/monitoring-rules/prometheusrule-external-secrets.yaml|s/^\([[:space:]]*\)externalsecret_status_condition{condition="Ready",status="False"}$/\1externalsecret_status_condition{status="False"}/
# prometheusrule-home-assistant.yaml (REN-245). Detected by the matching
# `eval_time: 14m` / `15m` pairs in home-assistant_test.yaml.
ha-down-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|/alert: HomeAssistantDown/,/for:/s/for: 5m/for: 10m/
# Drops the label normalisation: the alert changes identity when the target
# comes back failing, and the t=25m assertion loses it.
ha-down-no-max|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/max by (job, namespace) (up{job="home-assistant", namespace="home-assistant"}) == 0/up{job="home-assistant", namespace="home-assistant"} == 0/
# The instance floor pinned to spec.instances. At 3 the healthy two-instance
# cluster pages. Detected by the switchover case, which must stay silent.
ha-recorder-count-3|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/== 1) < 2$/== 1) < 3/
# The loose regex that lets a Ready bootstrap Job pod mask a down broker.
ha-mosquitto-loose-regex|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/pod=~"mosquitto-\[a-z0-9\]{6,10}-\[a-z0-9\]{5}"/pod=~"mosquitto-.*"/
# Drops the bridge-sensor branch. Detected by the coordinator-offline case.
ha-z2m-no-bridge|kubernetes/apps/monitoring-rules/prometheusrule-home-assistant.yaml|s/bridge_connection_state"}) == 0$/bridge_connection_state"}) == 2/
# --- prometheusrule-ceph.yaml (REN-93) --------------------------------------
# The canary and the critical rule it guards share the `{type="replicated"}`
# join text, so these scope by the canary's `absent(` ... `)` block: inside it
# for the canary, `!` for everything else. The annotation quotes the selector
# with escaped quotes and so never matches.
# The window, shortened. At 15m the canary pages on a mgr-failover-length gap.
# Detected by: case F, the eval_time 75m silence.
ceph-blind-for-15m|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)for: 1h$/\1for: 15m/
# The window, lengthened. At 2h a real blindness that started at t=30m is
# still not reported at 95m.
# Detected by: case F, the eval_time 95m page.
ceph-blind-for-2h|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)for: 1h$/\1for: 2h/
# Drop the value filter from the canary only. It then watches a join the
# critical rule does not use, and stays quiet through a renamed `type` label
# or value while the critical rule is dark.
# Detected by: cases C and D.
ceph-blind-any-type|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|/^[[:space:]]*absent($/,/^[[:space:]]*)$/s/{type="replicated"}//
# Invert the canary: the bare join instead of its absence. It then fires on
# every healthy cluster and on every real redundancy loss, i.e. it masks the
# alert it exists to protect.
# Detected by: cases A and B.
ceph-blind-inverted|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/^\([[:space:]]*\)absent($/\1(/
# REN-88's gate, removed from the critical rule. Erasure-coded and renamed
# pools then match the critical rule again. Proves this suite loads and asserts
# the critical rule, not only the canary.
# Detected by: cases C, D and E, which assert the critical rule is silent.
ceph-critical-any-type|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|/^[[:space:]]*absent($/,/^[[:space:]]*)$/!s/{type="replicated"}//
# prometheusrule-prometheus-retention.yaml (REN-107). Detected by the cases in
# prometheus-retention_test.yaml.
retention-no-time-clause|kubernetes/apps/monitoring-rules/prometheusrule-prometheus-retention.yaml|s/< 0.9 \* min(prometheus_tsdb_retention_limit_seconds/< 100 * min(prometheus_tsdb_retention_limit_seconds/
retention-no-size-clause|kubernetes/apps/monitoring-rules/prometheusrule-prometheus-retention.yaml|s/^\([[:space:]]*\)> 0.8$/\1> 0/
retention-ratio-0.9|kubernetes/apps/monitoring-rules/prometheusrule-prometheus-retention.yaml|s/^\([[:space:]]*\)> 0.8$/\1> 0.9/
retention-lookback-1d|kubernetes/apps/monitoring-rules/prometheusrule-prometheus-retention.yaml|s/)\[7d:1h\]$/)[1d:1h]/
retention-cap-at-peak-time|kubernetes/apps/monitoring-rules/prometheusrule-prometheus-retention.yaml|s/\/ max(prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus"})$/\/ min_over_time(max(prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus"})[7d:1h])/
@@ -0,0 +1,218 @@
# promtool test rules for prometheusrule-prometheus-retention.yaml (REN-107).
#
# Run by the `promtool` step of .woodpecker/ci-manifests-test.yml through
# scripts/ci/promtool-rules.sh — see README.md here. Negative controls are the
# `retention-*` entries in mutations.txt.
#
# WHAT IT IS FOR. PrometheusRetentionNotInForce makes five claims in its
# comments, and each scenario below asserts one of them:
#
# 1. It fires on the state measured on 2026-09-26 (90d/30GiB, 20.5-27.2 day
# saw-tooth), across a pod restart and across the post-eviction trough.
# 2. It goes silent the moment the REN-107 fix (30d/40GiB) is in force,
# although the window on disk is still ~21 days and last week's peak was
# 87% of the OLD cap.
# 3. It stays silent at 30d steady state, where the store sits above 80% of
# the 40GiB cap but the time retention is what binds (clause 1 guards).
# 4. It stays silent on a freshly provisioned PVC (clause 2 guards).
# 5. It fires if the size cap binds under the new config.
#
# All series are hourly (the rule's subquery step) and evaluated hourly.
# `lowest_timestamp_seconds` is written as (t - window), so `time() - lowest`
# reads back the window: `-X+3600xN` holds a constant X-second window while
# Prometheus deletes old data, a constant value is a window that only grows.
# Byte figures follow the live pod: WAL ~0.5 GB, head chunks ~0.2 GB.
rule_files:
- ../prometheusrule-prometheus-retention.yaml
evaluation_interval: 1h
tests:
# --- 1. Today: 90d / 30GiB, size cap binds ------------------------------
# Pod a holds a 26.5-day window at 87.5% of the cap. At t=40h it is
# replaced (a chart upgrade) and pod b starts after an eviction: 20.5 GB,
# 66% of the cap, same 26.5-day window. The alert must stay up across the
# restart and the trough for as long as the peak is inside 7 days, and
# clear once it is not — the 7d window is what spans a saw-tooth tooth.
- interval: 1h
input_series:
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '7776000x39'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '32212254720x39'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '-2291239+3600x39'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '27500000000x39'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '500000000x39'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '200000000x39'
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 7776000x180'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 32212254720x180'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 -2147239+3600x180'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 20500000000x180'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 500000000x180'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x40 200000000x180'
alert_rule_test:
- eval_time: 0h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 1h
alertname: PrometheusRetentionNotInForce
exp_alerts: &retention_firing_26d
- exp_labels: {severity: warning, namespace: monitoring}
exp_annotations:
summary: "Prometheus retention setting is not in force — size cap binds first"
description: "Prometheus is holding only 26d 12h 27m 19s of history while its configured time retention is longer, and the store has been within 20% of its configured size cap in the last 7 days. The size cap is therefore what is actually in force and the `retention` value in kubernetes/apps/monitoring/helmrelease-prometheus.yaml is not being honoured — queries over a longer range return less data than requested, silently and successfully. Confirm the running pair with `GET /api/v1/status/runtimeinfo` -> `storageRetention`, then either raise `retentionSize` (and the PVC, which must stay ahead of it) or lower `retention` to a figure the storage budget can actually hold. Do not read `prometheus_tsdb_size_retentions_total` to decide whether this is real — it resets on every restart. Background: REN-107."
# After the restart and in the trough: the peak was pod a's.
- eval_time: 60h
alertname: PrometheusRetentionNotInForce
exp_alerts: *retention_firing_26d
- eval_time: 150h
alertname: PrometheusRetentionNotInForce
exp_alerts: *retention_firing_26d
# Pod a's last sample (39h) is out of the 7d subquery from 208h on.
- eval_time: 210h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
# --- 2. The fix day: 90d/30GiB -> 30d/40GiB -----------------------------
# Pod a is today's state just after an eviction (peak 28.2 GB = 87.5% of
# 30GiB until t=79h, then 20.5 GB). The fix rolls out at t=100h; pod b holds
# the data it inherited and deletes nothing, so its window grows from 20.8
# days and is still under 27 days (90% of 30d) until t~=250h. Clause 1 is
# therefore true throughout; the rule must be silent anyway, because the
# 28.2 GB peak is 66% of the cap now in force.
- interval: 1h
input_series:
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '7776000x99'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '32212254720x99'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '-1771200+3600x99'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '27500000000x79 20500000000x19'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '500000000x99'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '200000000x99'
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 2592000x160'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 42949672960x160'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 -1411200x160'
# ~1.02 GB/day of ingest, nothing deleted.
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 20500000000+42500000x160'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 500000000x160'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-b"}'
values: '_x100 200000000x160'
alert_rule_test:
# Before the fix it fires (window 20.5 days, peak within 7 days).
- eval_time: 99h
alertname: PrometheusRetentionNotInForce
exp_alerts:
- exp_labels: {severity: warning, namespace: monitoring}
exp_annotations:
summary: "Prometheus retention setting is not in force — size cap binds first"
description: "Prometheus is holding only 20d 12h 0m 0s of history while its configured time retention is longer, and the store has been within 20% of its configured size cap in the last 7 days. The size cap is therefore what is actually in force and the `retention` value in kubernetes/apps/monitoring/helmrelease-prometheus.yaml is not being honoured — queries over a longer range return less data than requested, silently and successfully. Confirm the running pair with `GET /api/v1/status/runtimeinfo` -> `storageRetention`, then either raise `retentionSize` (and the PVC, which must stay ahead of it) or lower `retention` to a figure the storage budget can actually hold. Do not read `prometheus_tsdb_size_retentions_total` to decide whether this is real — it resets on every restart. Background: REN-107."
- eval_time: 100h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 110h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 170h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 250h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
# --- 3. 30d steady state -------------------------------------------------
# Time retention binds: the window never drops below 30 days (it runs to
# ~31.5 while a block waits for deletion) and the store sits at 38 GB,
# 88.5% of 40GiB. Clause 2 is true all the time; clause 1 must keep the
# rule silent.
- interval: 1h
input_series:
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '2592000x200'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '42949672960x200'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '-2721600+3600x200'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '37300000000x200'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '500000000x200'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '200000000x200'
alert_rule_test:
- eval_time: 2h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 180h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
# --- 4. Fresh PVC --------------------------------------------------------
# A new volume holds hours of data against a 30d setting: clause 1 is true
# for 27 days. Clause 2 (7.6 GB at t=180h, 18% of the cap) must keep it
# silent.
- interval: 1h
input_series:
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '2592000x200'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '42949672960x200'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '0x200'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '0+42500000x200'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '500000000x200'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '200000000x200'
alert_rule_test:
- eval_time: 2h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
- eval_time: 180h
alertname: PrometheusRetentionNotInForce
exp_alerts: []
# --- 5. The size cap binds again under 30d/40GiB ------------------------
# Ingest grew: the store is at 38 GB (88.5%) and holds only 25 days.
- interval: 1h
input_series:
- series: 'prometheus_tsdb_retention_limit_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '2592000x10'
- series: 'prometheus_tsdb_retention_limit_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '42949672960x10'
- series: 'prometheus_tsdb_lowest_timestamp_seconds{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '-2160000+3600x10'
- series: 'prometheus_tsdb_storage_blocks_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '37300000000x10'
- series: 'prometheus_tsdb_wal_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '500000000x10'
- series: 'prometheus_tsdb_head_chunks_storage_size_bytes{job="kube-prometheus-stack-prometheus",pod="prometheus-a"}'
values: '200000000x10'
alert_rule_test:
- eval_time: 2h
alertname: PrometheusRetentionNotInForce
exp_alerts:
- exp_labels: {severity: warning, namespace: monitoring}
exp_annotations:
summary: "Prometheus retention setting is not in force — size cap binds first"
description: "Prometheus is holding only 25d 0h 0m 0s of history while its configured time retention is longer, and the store has been within 20% of its configured size cap in the last 7 days. The size cap is therefore what is actually in force and the `retention` value in kubernetes/apps/monitoring/helmrelease-prometheus.yaml is not being honoured — queries over a longer range return less data than requested, silently and successfully. Confirm the running pair with `GET /api/v1/status/runtimeinfo` -> `storageRetention`, then either raise `retentionSize` (and the PVC, which must stay ahead of it) or lower `retention` to a figure the storage budget can actually hold. Do not read `prometheus_tsdb_size_retentions_total` to decide whether this is real — it resets on every restart. Background: REN-107."
@@ -2,6 +2,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: blackbox-exporter
# anchor: namespace
namespace: monitoring
spec:
interval: 30m
@@ -17,7 +17,7 @@ spec:
chart:
spec:
chart: kube-prometheus-stack
version: ">=72.0.0"
version: "91.7.0"
sourceRef:
kind: HelmRepository
name: prometheus-community
@@ -35,7 +35,7 @@ spec:
prometheus:
# prometheus.renner.uno is served by a standalone, Authentik-gated
# Ingress (prometheus-gate-ingress.yaml), NOT this Helm-generated one.
# Prometheus had no auth of its own — its full 90d metrics DB is an
# Prometheus had no auth of its own — its full 30d metrics DB is an
# infra-recon oracle (hostnames, IPs, service inventory). Gated behind
# the prometheus-gate proxy outpost, same as alertmanager-gate (#146).
# The chart Ingress is disabled here — two Ingresses for one host would
@@ -76,8 +76,17 @@ spec:
# Rewrites the in-cluster URL embedded in alert generatorURLs and
# status pages so click-through from emails works.
externalUrl: https://prometheus.renner.uno
retention: 90d
retentionSize: 30GB
# Time retention is the one in force; the size cap is a backstop
# (REN-107, owner decision: 30 days on the existing disk). This was
# 90d/30GB, and at ~1 GB/day of ingest the 30GiB cap bound first: the
# real window was a 20.5-27 day saw-tooth while this line said 90d.
# 30d peaks near 38 GB (30d plus up to one 3-day block awaiting
# deletion, plus WAL), under 40GiB (42.9 GB), under the 50Gi PVC.
# Raising either number means re-doing that arithmetic against the
# PVC; PrometheusRetentionNotInForce (monitoring-rules) fires if the
# size cap starts binding again.
retention: 30d
retentionSize: 40GiB
scrapeInterval: 30s
evaluationInterval: 30s
storageSpec:
@@ -304,6 +313,10 @@ spec:
- matchers:
- alertname = "KubeCPUOvercommit"
receiver: 'null'
# Helper alert that only drives the info inhibit rule below.
- matchers:
- alertname = "InfoInhibitor"
receiver: 'null'
# Flux notification-controller posts info-severity events
# here (e.g. image-automation commits). Keep them visible
# but don't repeat-spam every 12h on the same event.
@@ -313,6 +326,23 @@ spec:
group_wait: 1m
group_interval: 10m
repeat_interval: 24h
# From kube-prometheus manifests/alertmanager-secret.yaml.
inhibit_rules:
- source_matchers:
- severity = "critical"
target_matchers:
- severity =~ "warning|info"
equal: ['namespace', 'alertname']
- source_matchers:
- severity = "warning"
target_matchers:
- severity = "info"
equal: ['namespace', 'alertname']
- source_matchers:
- alertname = "InfoInhibitor"
target_matchers:
- severity = "info"
equal: ['namespace']
receivers:
- name: email
email_configs:
@@ -339,6 +369,285 @@ spec:
limits:
memory: 256Mi
# --- Flux per-object readiness (REN-102) ---------------------------------
#
# WHY THIS IS HERE AND NOT ON THE FLUX CONTROLLERS. `gotk_reconcile_condition`
# and `gotk_suspend_status` are still declared by fluxcd/pkg/runtime, but no
# controller populates them at the tags pinned in
# clusters/homelab/flux-system/gotk-components.yaml, and an unpopulated
# GaugeVec exports no series. Verified after the REN-97 scrape landed: the
# PodMonitor now yields 2,273 `gotk_*` series and
# `count({__name__=~"gotk_reconcile_condition|gotk_suspend_status"})` is still
# empty. Upstream moved per-object readiness to kube-state-metrics
# custom-resource-state, which is what this block turns on.
#
# DO NOT ADD `collectors: []` OR `--custom-resource-state-only=true`.
# Upstream's reference config (fluxcd/flux2-monitoring-example,
# monitoring/controllers/kube-prometheus-stack/kube-state-metrics-config.yaml)
# opens with exactly those two lines. Copying them replaces every built-in
# collector with the Flux ones: measured 2026-09-26, that would take
# `count({job="kube-state-metrics"})` from 27,792 series to ~183 and blind
# every `Kube*` alert in the chart. Both keys are deliberately absent below.
#
# ROLLS THE kube-state-metrics POD. Enabling customResourceState adds an arg
# and a volume to the Deployment, so the pod is replaced once on apply. That
# is the whole blast radius of this block — KSM only watches, it writes
# nothing, and no Flux object or workload is touched. During the roll (a few
# seconds) `kube_*` has a gap; `KubeStateMetricsDown` and `TargetDown` cover
# it if it does not come back.
#
# NOTE FOR LATER EDITS: the subchart puts no config checksum on the
# Deployment's pod template, so editing `config` below changes the ConfigMap
# WITHOUT rolling the pod, and KSM keeps serving the old config until it
# restarts for another reason. Any future change to this config needs a
# deliberate pod restart to take effect.
#
# RBAC: list + watch, nothing else. `get` is NOT needed — KSM drives
# reflectors, never point-reads. Also note the chart adds
# `customresourcedefinitions: [list, watch]` on its own via
# rbac.customResourceState.createClusterRoleRules (default true); KSM needs it
# to resolve the GVKs below. Both land in the existing
# kube-prometheus-stack-kube-state-metrics ClusterRole rather than a second
# binding to maintain. No write verb, no wildcard, no cluster-admin.
rbac:
extraRules:
- apiGroups:
- source.toolkit.fluxcd.io
- kustomize.toolkit.fluxcd.io
- helm.toolkit.fluxcd.io
- image.toolkit.fluxcd.io
# Trimmed to the kinds this cluster actually has. Omitted from
# upstream's list, each for a checked reason:
# buckets, receivers — zero objects exist in this repo.
# helmcharts — created by source-controller at
# runtime, one per HelmRelease; a not-Ready HelmChart is always
# mirrored by the HelmRelease that owns it, so alerting on both
# is one failure reported twice.
# alerts, providers — notification.toolkit.fluxcd.io/v1beta3
# Alert and Provider have NO status.conditions in the CRD at all
# (checked in gotk-components.yaml), so there is no readiness to
# report; upstream omits `ready` for them for the same reason. A
# series carrying only `suspended` is not alertable.
resources:
- gitrepositories
- helmrepositories
- ocirepositories
- kustomizations
- helmreleases
- imagerepositories
- imagepolicies
- imageupdateautomations
verbs: ["list", "watch"]
customResourceState:
enabled: true
config:
spec:
resources:
# Label set is upstream's, minus the churning ones. Dropped
# deliberately: `revision` (status.artifact.revision /
# lastAppliedRevision — a git SHA, so every commit that reaches a
# Kustomization mints a NEW SERIES), and `url` / `endpoint`.
# Prometheus is at 84.5% of its 30GB retentionSize cap (REN-107);
# a label that changes several times a day across 33 Kustomizations
# is exactly what must not be added. Kept: `ready` and `suspended`
# (what the alerts read), `name` / `exported_namespace` (identity),
# `source_name` (the join key, see prometheusrule-flux.yaml), plus
# `image` and `tag` on the two image kinds, which are stable enough
# to ride the existing series.
#
# `exported_namespace` is upstream's own label name for this, not a
# Prometheus collision rename: the chart's KSM ServiceMonitor runs
# honorLabels, so `namespace` on a KSM series is already the
# OBJECT's namespace and nothing gets renamed. The rules use the
# same string because the config declares it.
#
# KSM adds `customresource_group`, `customresource_version` and
# `customresource_kind` to every custom-resource-state series by
# itself. The rules key off `customresource_kind`.
- groupVersionKind:
group: kustomize.toolkit.fluxcd.io
version: v1
kind: Kustomization
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux Kustomization resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
source_name: [spec, sourceRef, name]
- groupVersionKind:
group: helm.toolkit.fluxcd.io
version: v2
kind: HelmRelease
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux HelmRelease resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: GitRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux GitRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: HelmRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux HelmRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: source.toolkit.fluxcd.io
version: v1
kind: OCIRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux OCIRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImageRepository
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImageRepository resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
image: [spec, image]
# The repository-side half of the Ready-but-frozen signal. A
# policy can hold Ready=True forever while its filter excludes
# every new release, so readiness cannot see it; what can is
# "the repository kept finding tags and the policy's answer
# never moved". .status.lastScanResult.tagCount is a required
# field on this CRD, so the series exists for every object.
- name: "image_repository_tag_count"
help: "Number of tags found by the ImageRepository's last scan."
each:
type: Gauge
gauge:
path: [status, lastScanResult]
valueFrom: [tagCount]
# `name` belongs HERE and not under `gauge`. KSM resolves
# `gauge.labelsFromPath` RELATIVE TO `gauge.path`, so
# [metadata, name] under there would be looked up inside
# status.lastScanResult and silently yield no label. The
# metric-level labelsFromPath resolves from the object root.
labelsFromPath:
name: [metadata, name]
exported_namespace: [metadata, namespace]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImagePolicy
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImagePolicy resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
# The resolved tag, as a label on the Info series that
# already exists — so the policy-side half of the frozen
# signal costs zero extra series at rest. It retires one
# series and starts another only when the tag actually
# changes: 191 automated bumps in 147 days across 34
# policies is ~1.3 new series/day, which ages out with
# retention.
tag: [status, latestRef, tag]
# The join key. `metadata.name` happens to equal
# `spec.imageRepositoryRef.name` for all 34 pairs in this
# repo today, but the REFERENCE is this field, and a policy
# named differently from its repository would silently drop
# out of a name-based join.
source_name: [spec, imageRepositoryRef, name]
- groupVersionKind:
group: image.toolkit.fluxcd.io
version: v1
kind: ImageUpdateAutomation
metricNamePrefix: gotk
errorLogV: 10
metrics:
- name: "resource_info"
help: "The current state of a Flux ImageUpdateAutomation resource."
each:
type: Info
info:
labelsFromPath:
name: [metadata, name]
labelsFromPath:
exported_namespace: [metadata, namespace]
ready: [status, conditions, "[type=Ready]", status]
suspended: [spec, suspend]
source_name: [spec, sourceRef, name]
# Drop the apiserver's high-cardinality latency histograms.
#
# Measured 2026-08-20: Prometheus held 298,236 active series, of which
+210
View File
@@ -0,0 +1,210 @@
# Beets fixes the tags of the music library in place, once a night (REN-349).
# It never moves, copies, renames or deletes a file: Lidarr and Navidrome
# paths stay valid. Navidrome picks up the new tags on its hourly scan.
#
# REN-350: two dry runs with write: no were checked (match report and a
# 5-album spot check), so beets now writes tags. Art plugins stay off: they
# would add or embed cover files, a separate change.
#
# Retry: the first write run matched 74 of 560 albums; most skipped folders
# hold only part of an album. Partial-album matches are allowed now (see
# max_rec). A -W trial matched 158 albums, all at 96% or better, and none of
# the 74 written albums changed release, so beets now writes them. Muse "Black
# Holes and Revelations" matched an instrumental release: it is ignored and
# dropped from the DB (DB only, no file change).
#
# Lidarr does not write tags: lidarr-reconcile sets writeAudioTags "no" on
# every pod start (lidarr.yaml), so it cannot undo the beets tags.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: beets-config
namespace: navidrome
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: beets-config
namespace: navidrome
data:
config.yaml: |
directory: /music
library: /config/library.db
statefile: /config/state.pickle
plugins: [musicbrainz, fetchart, embedart, lastgenre]
# Beets defaults plus folders whose only match is a wrong release.
ignore: ['.*', '*~', 'System Volume Information', 'lost+found',
'Black Holes and Revelations']
# Keep the first-release date in year; reissues must not change it.
original_date: yes
match:
preferred:
countries: ['DE', 'XE', 'XW', 'US', 'GB|UK']
media: ['CD', 'Digital Media|File']
original_year: yes
# Partial albums may match; a missing track costs less than a wrong
# title or artist (defaults: medium cap, weight 0.9).
max_rec:
missing_tracks: strong
distance_weights:
missing_tracks: 0.2
import:
write: yes
copy: no
move: no
link: no
hardlink: no
reflink: no
delete: no
quiet: yes
quiet_fallback: skip
incremental: yes
# Do not record skipped folders: they are retried the next night.
incremental_skip_later: yes
resume: no
# Never "remove": that deletes files.
duplicate_action: skip
# Both add or change cover art in /music; not part of the tag fix.
fetchart:
auto: no
embedart:
auto: no
lastgenre:
auto: yes
run.sh: |
set -u
beet() { /lsiopy/bin/beet -c /etc/beets/config.yaml "$@"; }
mkdir -p /config/logs
# One-time reset: the first dry run stored reissue matches in the DB.
# The DB is beets state only; no file in /music is touched.
if [ ! -f /config/.reset-ren350 ]; then
rm -f /config/library.db /config/state.pickle
touch /config/.reset-ren350
fi
# One-time: forget the skipped folders so they are retried. The 74
# written albums are re-imported too: beets re-matches them under the
# looser weights and replaces their DB entry (no duplicate skip).
if [ ! -f /config/.retry-ren350 ]; then
rm -f /config/state.pickle
touch /config/.retry-ren350
fi
# One-time: the trial stored an instrumental release for this album.
# `rm` without -d drops the DB entry only; the file is not touched.
if [ ! -f /config/.drop-ren350 ]; then
beet rm -a -f 'path:/music/Muse/Black Holes and Revelations'
touch /config/.drop-ren350
fi
beet ls -a -f '$path $mb_albumid' | sort > /config/logs/albums-before.txt
LOG=/config/logs/import-$(date +%Y%m%d-%H%M).log
beet import -l "$LOG" /music
rc=$?
beet ls -a -f '$path $mb_albumid' | sort > /config/logs/albums-after.txt
comm -23 /config/logs/albums-before.txt /config/logs/albums-after.txt \
> /config/logs/albums-changed.txt
echo "== import exit $rc"
echo "== albums with a changed release: $(wc -l < /config/logs/albums-changed.txt)"
cat /config/logs/albums-changed.txt
echo "== albums in library: $(beet ls -a | wc -l)"
echo "== skipped this run: $(grep -c '^skip ' "$LOG")"
echo "== duplicates skipped: $(grep -c '^duplicate-skip ' "$LOG")"
# The trial (-W) stored matches in the DB only; `beet write` puts them
# into the files (tags only, no move).
beet write -p > /config/logs/write-preview.txt 2>&1
echo "== items to write: $(grep -vc '^ ' /config/logs/write-preview.txt)"
beet write
wrc=$?
echo "== write exit $wrc"
beet write -p > /config/logs/write-after.txt 2>&1
echo "== items still differing after write: $(grep -vc '^ ' /config/logs/write-after.txt)"
echo "== skipped albums"
grep '^skip ' "$LOG"
echo "== written changes (first 2000 lines)"
head -n 2000 /config/logs/write-preview.txt
[ "$rc" -eq 0 ] && rc=$wrc
exit $rc
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: beets
namespace: navidrome
labels:
app.kubernetes.io/name: beets
spec:
schedule: "30 3 * * *"
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 0
# The first full import is rate-limited by MusicBrainz (1 req/s). An
# interrupted run is safe: incremental import resumes the next night.
activeDeadlineSeconds: 72000
template:
metadata:
labels:
app.kubernetes.io/name: beets
spec:
restartPolicy: Never
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: beets
image: lscr.io/linuxserver/beets:2.14.1-ls354
# Bypass the s6 init: it needs root, beet does not.
command: ["sh", "/etc/beets/run.sh"]
env:
- name: TZ
value: Europe/Berlin
- name: BEETSDIR
value: /config
- name: HOME
value: /config
volumeMounts:
- name: config
mountPath: /config
- name: beets-config
mountPath: /etc/beets
readOnly: true
# Writable for tag writes; move/copy/delete stay off above.
- name: music
mountPath: /music
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: config
persistentVolumeClaim:
claimName: beets-config
- name: beets-config
configMap:
name: beets-config
- name: music
persistentVolumeClaim:
claimName: navidrome-music-bulk
+130
View File
@@ -0,0 +1,130 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: navidrome-config
namespace: navidrome
data:
TZ: "Europe/Berlin"
ND_LOGLEVEL: "info"
ND_SCANNER_SCHEDULE: "0 * * * *"
ND_ENABLEINSIGHTSCOLLECTOR: "false"
# Identity from the navidrome-gate outpost. Traefik strips this header on
# the ungated /rest and /share routes.
#
# ND_EXTAUTH_TRUSTEDSOURCES BELOW IS NOT A NARROWING. fd10:244::/48 plus
# 10.244.0.0/16 is the entire cluster pod CIDR, so navidrome's own
# reverse-proxy trust check accepts X-Authentik-Username from ANY pod.
# The CiliumNetworkPolicy is the only real boundary, and it is wider than
# "the outpost and Traefik" — default-deny-ingress in
# kubernetes/infrastructure/network-policies/navidrome-ns.yaml uses
# `endpointSelector: {}` and admits, on every port:
# - the navidrome namespace, every port
# - traefik, every port
# - the navidrome-gate outpost, every port
# - the lidarr-gate outpost, every port - reaches navidrome too
# - host / remote-node, every port - the kubelet sources the HTTP probes
# from the host netns, so this one can never be removed while the pod
# has HTTP probes
# - blackbox-exporter on :4533 and :8686 only
# The whole-monitoring-namespace admit that used to be in this list was
# narrowed to blackbox-exporter on those two ports in REN-28. Rules are
# cited by what they admit, not by line number, so this list cannot go
# stale the next time that file is edited.
# The value stays at the pod CIDR ON PURPOSE - see REN-28. The address
# navidrome sees is the outpost's POD IP, which is not stable across
# reschedules, so a narrower literal here fails open-ended and silently
# the next time that pod moves. Tightening the CNP is the control that
# would actually do work.
ND_EXTAUTH_USERHEADER: "X-Authentik-Username"
ND_EXTAUTH_TRUSTEDSOURCES: "fd10:244::/48,10.244.0.0/16"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: navidrome
namespace: navidrome
labels:
app.kubernetes.io/name: navidrome
annotations:
configmap.reloader.stakater.com/reload: navidrome-config
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: navidrome
template:
metadata:
labels:
app.kubernetes.io/name: navidrome
spec:
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: navidrome
image: deluan/navidrome:0.64.2 # {"$imagepolicy": "flux-system:navidrome"}
ports:
- name: http
containerPort: 4533
envFrom:
- configMapRef:
name: navidrome-config
volumeMounts:
- name: data
mountPath: /data
- name: music
mountPath: /music
readOnly: true
# anchor: kubelet-probes
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: navidrome-data
- name: music
persistentVolumeClaim:
claimName: navidrome-music-bulk
---
apiVersion: v1
kind: Service
metadata:
name: navidrome
namespace: navidrome
spec:
selector:
app.kubernetes.io/name: navidrome
ports:
- name: http
port: 80
targetPort: http
@@ -0,0 +1,13 @@
# ExternalName mirror so the navidrome-namespace Ingress can backend to the
# navidrome-gate outpost in the authentik namespace.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-navidrome-gate
namespace: navidrome
spec:
type: ExternalName
externalName: ak-outpost-navidrome-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
+93
View File
@@ -0,0 +1,93 @@
# Web UI behind the navidrome-gate outpost.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: navidrome
namespace: navidrome
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: navidrome.renner.uno
http:
paths:
- path: /
pathType: Prefix
# anchor: navidrome-gate
backend:
service:
name: ak-outpost-navidrome-gate
port:
number: 9000
tls:
- secretName: navidrome-tls
hosts:
- navidrome.renner.uno
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: navidrome-strip-identity
namespace: navidrome
spec:
headers:
customRequestHeaders:
X-Authentik-Username: ""
---
# Subsonic API and public shares bypass the gate; clients authenticate with
# their Navidrome password.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: navidrome-subsonic
namespace: navidrome
labels:
dns.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: navidrome-navidrome-strip-identity@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: navidrome.renner.uno
http:
paths:
- path: /rest
pathType: Prefix
backend:
service:
name: navidrome
port:
number: 80
- path: /share
pathType: Prefix
backend:
service:
name: navidrome
port:
number: 80
---
# Native API (/auth, /api) for clients that cannot follow the SSO redirect
# (Koda on Garmin logs in with POST /auth/login). Requests carrying an
# authentik_proxy_ session cookie (the web UI) keep going through the gate.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: navidrome-native-api
namespace: navidrome
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`navidrome.renner.uno`) && (PathPrefix(`/auth`) || PathPrefix(`/api`)) && !HeaderRegexp(`Cookie`, `authentik_proxy_`)
priority: 1000
middlewares:
- name: navidrome-strip-identity
services:
- name: navidrome
port: 80
tls:
secretName: navidrome-tls
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvcs.yaml
- deployment.yaml
- gate-service.yaml
- ingress.yaml
- lidarr.yaml
- beets.yaml
+227
View File
@@ -0,0 +1,227 @@
# Lidarr as a read-mostly "what is missing" view over the Navidrome library.
# No download client or indexer is configured.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lidarr-config
namespace: navidrome
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 5Gi
---
# Applied on every pod start, before the root folder exists: no renames, no tag
# writes, no deletes. Adding /music as root folder starts Lidarr's library import.
apiVersion: v1
kind: ConfigMap
metadata:
name: lidarr-reconcile
namespace: navidrome
data:
# anchor: reconcile-script
reconcile.sh: |
set -eu
API=http://localhost:8686/api/v1
until curl -sf http://localhost:8686/ping >/dev/null; do sleep 5; done
KEY=$(sed -n 's:.*<ApiKey>\(.*\)</ApiKey>.*:\1:p' /config/config.xml)
if [ -z "$KEY" ]; then
echo "FATAL: no ApiKey in /config/config.xml" >&2
exit 1
fi
H="X-Api-Key: $KEY"
put() {
cur=$(curl -sf -H "$H" "$API/$1")
id=$(printf '%s' "$cur" | jq -r '.id')
printf '%s' "$cur" | jq -c "$2" \
| curl -sf -X PUT -H "$H" -H 'Content-Type: application/json' -d @- "$API/$1/$id" >/dev/null
echo "reconciled $1"
}
put config/naming '.renameTracks = false'
put config/metadataprovider '.writeAudioTags = "no" | .scrubAudioTags = false | .embedCoverArt = false'
put config/mediamanagement '.deleteEmptyFolders = false | .createEmptyArtistFolders = false
| .recycleBin = "" | .setPermissionsLinux = false | .importExtraFiles = false
| .skipFreeSpaceCheckWhenImporting = true'
roots=$(curl -sf -H "$H" "$API/rootfolder" | jq '[.[] | select(.path == "/music")] | length')
if [ "$roots" = "0" ]; then
qp=$(curl -sf -H "$H" "$API/qualityprofile" | jq '.[0].id')
mp=$(curl -sf -H "$H" "$API/metadataprofile" | jq '([.[] | select(.name == "Standard")][0].id) // .[0].id')
jq -nc --argjson qp "$qp" --argjson mp "$mp" '{
name: "Music", path: "/music",
defaultQualityProfileId: $qp, defaultMetadataProfileId: $mp,
defaultMonitorOption: "all", defaultNewItemMonitorOption: "all",
defaultTags: []
}' \
| curl -sf -X POST -H "$H" -H 'Content-Type: application/json' -d @- "$API/rootfolder" >/dev/null
echo "added root folder /music"
fi
echo "reconcile done"
while true; do sleep 3600; done
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lidarr
namespace: navidrome
labels:
app.kubernetes.io/name: lidarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: lidarr
template:
metadata:
labels:
app.kubernetes.io/name: lidarr
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Europe/Berlin
# Identity is enforced by the lidarr-gate outpost.
- name: LIDARR__AUTH__METHOD
value: External
ports:
- name: http
containerPort: 8686
volumeMounts:
- name: config
mountPath: /config
- name: music
mountPath: /music
# anchor: kubelet-probes
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: reconcile
mountPath: /scripts
readOnly: true
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: config
persistentVolumeClaim:
claimName: lidarr-config
- name: music
persistentVolumeClaim:
claimName: navidrome-music-bulk
- name: reconcile
configMap:
name: lidarr-reconcile
---
apiVersion: v1
kind: Service
metadata:
name: lidarr
namespace: navidrome
spec:
selector:
app.kubernetes.io/name: lidarr
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-lidarr-gate
namespace: navidrome
spec:
type: ExternalName
externalName: ak-outpost-lidarr-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lidarr
namespace: navidrome
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: lidarr.renner.uno
http:
paths:
- path: /
pathType: Prefix
# anchor: lidarr-gate
backend:
service:
name: ak-outpost-lidarr-gate
port:
number: 9000
tls:
- secretName: lidarr-tls
hosts:
- lidarr.renner.uno
+31
View File
@@ -0,0 +1,31 @@
# SQLite database + artwork/transcode cache; single writer.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: navidrome-data
namespace: navidrome
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
# Music library on the EC bulk pool.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: navidrome-music-bulk
namespace: navidrome
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem-bulk
resources:
requests:
storage: 300Gi
@@ -0,0 +1,214 @@
---
# Per-agent kubectl identities for agent pods (REN-412, parent REN-284).
#
# Each agent gets its own ServiceAccount instead of the shared pod token of
# `paperclip-prometheus-read`. Atlas, Iris, Hestia and Argus get exactly what
# they hold today through that shared account: built-in `view` cluster-wide
# (no Secrets, no write verb) plus the `monitoring/paperclip-prometheus-read`
# Role (see prometheus-read-rbac.yaml). Hermes gets only `get` on the five
# token Secrets below, to issue and rotate the kubeconfigs.
#
# Token Secrets carry no data in git; kube-controller-manager fills
# `data.token`. Flux applies server-side and does not own `data`, so it does
# not wipe the token. Revoke one agent: delete its Secret (the token dies at
# once; Flux recreates it with a fresh token on the next reconcile).
#
# The shared account may read the tokens too (Hermes still runs in the shared
# pod). Each token is worth at most what the shared account already holds, so
# that is no escalation. Drop that subject once Hermes runs in its own pod.
#
# PRODUCTION
#
# ServiceAccounts, bindings and empty token Secrets only. No workload changes,
# nothing restarts. Revert: delete this file and its line in kustomization.yaml.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-atlas
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-iris
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-hestia
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-argus
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-hermes
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
automountServiceAccountToken: false
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
name: agent-atlas-token
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
kubernetes.io/service-account.name: agent-atlas
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
name: agent-iris-token
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
kubernetes.io/service-account.name: agent-iris
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
name: agent-hestia-token
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
kubernetes.io/service-account.name: agent-hestia
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
name: agent-argus-token
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
kubernetes.io/service-account.name: agent-argus
---
apiVersion: v1
kind: Secret
type: kubernetes.io/service-account-token
metadata:
name: agent-hermes-token
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
kubernetes.io/service-account.name: agent-hermes
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: paperclip-agents-view
labels:
app.kubernetes.io/name: paperclip
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: agent-atlas
namespace: paperclip
- kind: ServiceAccount
name: agent-iris
namespace: paperclip
- kind: ServiceAccount
name: agent-hestia
namespace: paperclip
- kind: ServiceAccount
name: agent-argus
namespace: paperclip
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: paperclip-agents-prometheus-read
namespace: monitoring
labels:
app.kubernetes.io/name: paperclip
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
# Existing Role in prometheus-read-rbac.yaml, unchanged.
name: paperclip-prometheus-read
# Subjects live in `paperclip`, the binding in `monitoring`: `namespace:` is
# load-bearing.
subjects:
- kind: ServiceAccount
name: agent-atlas
namespace: paperclip
- kind: ServiceAccount
name: agent-iris
namespace: paperclip
- kind: ServiceAccount
name: agent-hestia
namespace: paperclip
- kind: ServiceAccount
name: agent-argus
namespace: paperclip
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: agent-token-read
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames:
- agent-atlas-token
- agent-iris-token
- agent-hestia-token
- agent-argus-token
- agent-hermes-token
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: agent-token-read
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: agent-token-read
subjects:
# Hermes still runs in the shared pod; drop this subject once Hermes runs in
# its own pod.
- kind: ServiceAccount
name: paperclip-prometheus-read
namespace: paperclip
- kind: ServiceAccount
name: agent-hermes
namespace: paperclip
+62
View File
@@ -0,0 +1,62 @@
# paperclip-api.renner.uno — LAN-only machine door (no dns.renner.uno/public label).
# Bypasses the Authentik gate; Paperclip's bearer agent/board keys are the auth.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperclip-api
namespace: paperclip
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- paperclip-api.renner.uno
secretName: paperclip-api-tls
rules:
- host: paperclip-api.renner.uno
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: paperclip
port:
number: 3100
---
# Board sign-in stays behind the gate: every /api/auth request on this host is refused.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: deny-all
namespace: paperclip
spec:
ipAllowList:
sourceRange:
- 255.255.255.255/32
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperclip-api-auth-deny
namespace: paperclip
annotations:
traefik.ingress.kubernetes.io/router.middlewares: paperclip-deny-all@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- paperclip-api.renner.uno
secretName: paperclip-api-tls
rules:
- host: paperclip-api.renner.uno
http:
paths:
- path: /api/auth
pathType: Prefix
backend:
service:
name: paperclip
port:
number: 3100
@@ -0,0 +1,98 @@
---
# Cluster-wide READ for the agents in the paperclip pod (REN-111, decided on
# REN-52 2026-09-25 by the owner, option B).
#
# WHAT THIS IS
#
# One ClusterRoleBinding. It binds the Kubernetes built-in `view` ClusterRole to
# the ServiceAccount that REN-36 created in `../paperclip/prometheus-read-rbac.yaml`.
# No new ServiceAccount, no new ClusterRole, no write verb anywhere. `view` is
# maintained upstream and holds get/list/watch only; revocation is deleting this
# one object.
#
# WHY IT IS A SEPARATE FILE
#
# `prometheus-read-rbac.yaml` is deliberately narrow and its header argues for
# staying that way. This grant is the opposite in character — cluster-wide,
# every namespace — so it is kept in its own file and its own kustomization
# entry. Reverting it is deleting this file and one line from
# `kustomization.yaml`; nothing in the REN-36 path is touched, and the token
# falls back to `get services/proxy` on two named Services.
#
# WHY THE OWNER WANTED IT
#
# REN-108 part B had to validate a CiliumNetworkPolicy allowlist whose selector
# was `app.kubernetes.io/name: prometheus-blackbox-exporter`, and could not:
# kube-state-metrics here does not export `kube_pod_labels`, and
# blackbox-exporter is not a scrape target so Prometheus has no
# `__meta_kubernetes_pod_label_*` for it either. A CNP selector that matches no
# pod allows nothing and reports no error, so the proposal shipped unverified.
# `list pods` closes that, and the same gap recurs on every netpol or
# scrape-selector change.
#
# WHAT IT COSTS — ACCEPTED ON REN-52, NOT DISCOVERED HERE
#
# * EVERY process in the paperclip pod can read the mounted token, so this is
# a grant to every agent, not to one. REN-36 was approved on the premise
# that "the token is only worth what this Role grants"; this file changes
# that arithmetic, and the owner priced it knowingly.
# * `view` reads ConfigMaps in EVERY namespace. This repo keeps application
# configuration in ConfigMaps. Chosen over Hermes' narrower alternative.
# * `view` grants `pods/log`, which surfaces anything an application prints,
# including credentials it prints by mistake.
#
# WHAT IT DOES NOT GRANT
#
# * Secrets. `view` excludes them. "Secrets, metadata only" was the original
# ask and is NOT expressible in Kubernetes RBAC — the authorizer has no
# field-level verb — so that part of the ask is dead. Do not re-add Secrets
# here to approximate it.
# * Any write verb, on anything.
# * ClusterRoles and RoleBindings: `view` excludes the whole
# `rbac.authorization.k8s.io` group, on purpose, so a holder cannot read the
# shape of its own cage or find a Role to escalate through.
#
# FLUX CRDs — COVERED BY AGGREGATION, NO EXTRA ClusterRole NEEDED
#
# REN-52 pre-approved adding a read-only ClusterRole for the Flux CRDs *if*
# aggregation did not already cover them. It does, so none is added:
# `flux-view-flux-system` carries `rbac.authorization.k8s.io/aggregate-to-view:
# "true"` and lists get/list/watch on `*` across all six API groups —
# the `flux-view-flux-system` ClusterRole in
# `../../clusters/homelab/flux-system/gotk-components.yaml`. The
# aggregation controller merges those rules into `view` itself, so this one
# binding picks up kustomizations, helmreleases, gitrepositories, imagepolicies,
# imagerepositories and imageupdateautomations. Read from git, not from a live
# ClusterRole — `view` cannot read ClusterRoles, so it is unverifiable from
# inside the pod. The post-merge SelfSubjectAccessReview on REN-111 proves
# whether aggregation actually fired in the cluster; if `list kustomizations`
# comes back `allowed: false` there, the pre-approved ClusterRole is the fix.
#
# PRODUCTION
#
# Adding a ClusterRoleBinding mutates no workload, restarts no Pod and moves no
# traffic. RBAC is evaluated per request, so the grant takes effect on the
# already-mounted token with no remount and no rollout. That sequencing was the
# point of REN-52 putting this after REN-36: the paperclip Deployment restart
# was paid once, by REN-36, and is already paid.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: paperclip-cluster-view
labels:
app.kubernetes.io/name: paperclip
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
# Built-in, shipped by kube-controller-manager's default RBAC bootstrap. Not
# defined in this repo and must not be: it is auto-reconciled, so a local copy
# would be overwritten on every apiserver start.
name: view
subjects:
- kind: ServiceAccount
name: paperclip-prometheus-read
# A ClusterRoleBinding has no namespace of its own, so this is the only
# place the subject's namespace can be stated. Omit it and the binding
# resolves to no subject and fails closed, looking identical to a missing
# grant.
namespace: paperclip
+43
View File
@@ -0,0 +1,43 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pclp-pg
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
imageName: ghcr.io/cloudnative-pg/postgresql:17
storage:
storageClass: ceph-block
size: 10Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: pclp-pg-store
bootstrap:
initdb:
database: paperclip
owner: paperclip
postgresql:
parameters:
shared_buffers: "256MB"
max_connections: "200"
checkpoint_timeout: "15min"
max_wal_size: "2GB"
wal_compression: "zstd"
archive_timeout: "30min"
synchronous:
method: any
number: 1
dataDurability: preferred
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi

Some files were not shown because too many files have changed in this diff Show More