Compare commits

...
Author SHA1 Message Date
atlas-botandClaude Opus 5.5 20364c445e feat(mqtt): deploy mqtt-account-controller with a Dynamic Security test broker (REN-888)
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
REN-802 step 1. Adds:
- infrastructure/post-crds/mqtt-ca.yaml: private MQTT CA (self-signed root
  in cert-manager ns) and ClusterIssuer mqtt-ca.
- apps/home-assistant/mosquitto-dynsec-test.yaml: throwaway mosquitto 2.0.22
  with the dynsec plugin, mTLS listener (require_certificate,
  use_identity_as_username), initContainer that loads Secret
  mqtt-dynsec-seed or a static base file. ClusterIP only; not labelled
  mosquitto, so prod Services and the world-ingress policy do not select it.
- apps/home-assistant/mqtt-account-controller.yaml: ServiceAccount, RBAC
  (cluster-wide list certificates; certificates in home-assistant; secrets
  by resourceName only), controller client cert CN mqtt-account-controller,
  Deployment main-47213ef pointed at the test broker.
- apps/home-assistant/mqtt-accounts.yaml: register with two cert test
  accounts, delivered as ConfigMap mqtt-accounts.

The production broker is not changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:23:36 +00:00
security-bot c41b751596 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791297080@sha256:0764e882521410d8ce8ed1359f8a1aa64eaa7746aa909653ef70050c239ca195
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-06 14:47:22 +00:00
argus-bot (Paperclip service account) d86e07081a Merge pull request 'chore(paperclip): remove the one-shot lease receipt repair Job (REN-841)' (#1156) from atlas/ren-841-remove-repair-job into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 14:38:49 +00:00
atlas-botandClaude Opus 5.5 85614fb41b chore(paperclip): remove the one-shot lease receipt repair Job (REN-841)
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 Job ran once (UPDATE 2, leases f06b9278 and f6090b45).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:32:13 +00:00
argus-bot (Paperclip service account) 293b86a10d Merge pull request 'fix(paperclip): k8s plugin returns a termination receipt (REN-841)' (#1155) from atlas/ren-841-k8s-plugin-termination-receipt into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
2026-10-06 14:30:54 +00:00
argus-bot (Paperclip service account) 53bc71737d Merge pull request 'fix(paperclip): one-shot Job to record lease termination receipts (REN-841)' (#1154) from atlas/ren-841-lease-receipt-repair into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 14:28:17 +00:00
security-bot 57c6fa43bc chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1791295339
Updates managed by image-automation-controller.
2026-10-06 14:21:28 +00:00
atlas-botandClaude Opus 5.5 abc09ac722 fix(paperclip): k8s plugin returns a termination receipt (REN-841)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The kubernetes sandbox plugin's release and destroy hooks returned void,
so no lease got a termination receipt and every cancelled pod run left
its card waiting on remote_cleanup. Return state destroyed once the
Sandbox CR / Job (and pod) reads 404, within 20 s; otherwise no receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:57:41 +00:00
atlas-botandClaude Opus 5.5 ce80a15ab4 fix(paperclip): one-shot Job to record lease termination receipts (REN-841)
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
The kubernetes sandbox plugin returns no termination receipt, so the
leases of cancelled runs e08976cd (REN-837) and 144ae3bf (REN-825) were
released without one and the queued owner comments wait on
remote_cleanup forever. The controller log shows both Sandbox CRs
deleted. Write the missing receipt for exactly those leases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:55:01 +00:00
argus-bot (Paperclip service account) 53d4ab34fb Merge pull request 'openbao: suspend mosquitto open3e-int rotator (REN-802)' (#1153) from atlas/ren-802-suspend-open3e-int-rotator into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 13:17:20 +00:00
atlas-botandClaude Opus 5.5 e677986f3e openbao: suspend mosquitto open3e-int rotator (REN-802)
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
The quarterly rotation on 2026-12-01 would change the broker password while
the ESP32 gateway keeps the old one, taking it offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:48 +00:00
argus-bot (Paperclip service account) fdab588333 Merge pull request 'home-assistant: remove open3e room setpoint read Job (REN-826)' (#1152) from atlas/ren-826-remove-read-job 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-10-06 11:46:44 +00:00
atlas-botandClaude Opus 5.5 8eca50d83e home-assistant: remove open3e room setpoint read Job (REN-826)
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
The one-off read ran (2026-10-06 11:36-11:37 UTC); the result is on REN-826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:38:52 +00:00
argus-bot (Paperclip service account) 3a9686400d Merge pull request 'home-assistant: one-off read of open3e Room<N> DIDs 1884-1943 (REN-826)' (#1151) from atlas/ren-826-room-setpoint-read into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 11:34:52 +00:00
atlas-botandClaude Opus 5.5 6c38c88ce4 home-assistant: one-off read of open3e Room<N> DIDs 1884-1943 (REN-826)
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
One-shot Job that sends three read commands on open3e/cmnd to edge-1's
running open3e (read-json + read-raw on 0x6c3, one read-json on 0x6e0)
and prints the replies for Loki. No write mode, no config change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:24:27 +00:00
argus-bot (Paperclip service account) a3b53ac798 Merge pull request 'home-assistant: remove open3e FBH capture Job (REN-809)' (#1150) from atlas/ren-809-remove-capture into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 10:59:09 +00:00
atlas-botandClaude Opus 5.5 fe1bc81bfd home-assistant: remove open3e FBH capture Job (REN-809)
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
The capture answered REN-805 after 24 min (no setpoint in bytes 42-56);
the owner stopped the remaining 24 h window. Flux prune deletes the Job
and its pod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:57:07 +00:00
argus-bot (Paperclip service account) 7b7c685a8f Merge pull request 'home-assistant: one-off 24 h read-only open3e FBH payload capture (REN-809)' (#1149) from atlas/ren-809-open3e-fbh-capture into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 10:30:25 +00:00
atlas-botandClaude Opus 5.5 5e473af8b6 home-assistant: match spelled-out ZigBee index in FBH capture filter (REN-812)
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
open3e names the topics ..._ZigBeeSixDeviceCurrentValues, so the
[0-9]+ pattern matched none of the 18 DIDs (Argus review 1437).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:24:53 +00:00
atlas-botandClaude Opus 5.5 eca02a384c home-assistant: one-off 24 h read-only capture of FBH ZigBee payloads (REN-809)
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
Job subscribes to open3e/+ as the read-only evcc broker user and prints the
18 One Base FBH ZigBee<N>DeviceCurrentValues messages with a UTC timestamp
to stdout (Loki). Ends after 24 h via mosquitto_sub -W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:20:05 +00:00
security-bot a0d2167f95 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1791267662
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-06 06:51:27 +00:00
argus-bot (Paperclip service account) a9798b7b9a Merge pull request 'chore(woodpecker): remove the one-shot repo 4 re-key Job (REN-772)' (#1147) from atlas/ren-772-remove-rekey-job into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 05:45:57 +00:00
atlas-botandClaude Opus 5.5 9a1a3f65da chore(woodpecker): remove the one-shot repo 4 re-key Job (REN-772)
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
The Job ran: UPDATE 1, length(hash) 64. Flux prunes the completed Job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:44:10 +00:00
argus-bot (Paperclip service account) 8d6a26c119 Merge pull request 'fix(woodpecker): one-shot Job to re-key repo 4 hook hash (REN-772)' (#1146) from atlas/ren-772-wp-repo4-rekey into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 05:40:45 +00:00
atlas-botandClaude Opus 5.5 068e7258bb fix(woodpecker): one-shot Job to re-key repo 4 hook hash (REN-772)
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
The authentik-samba-bridge hook token was burned in a run log (REN-729).
Woodpecker 3.18 cannot re-key a repo hash via API, so a one-shot Job
sets a new random repos.hash generated inside Postgres. Remove after run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:35:16 +00:00
argus-bot (Paperclip service account) acc092cfd0 Merge pull request 'chore(mdns-relay): restart once to check the v1.9.1 net1 attach' (#1145) from atlas/ren-722-mdns-relay-v191-attach into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 05:07:42 +00:00
atlas-botandClaude Opus 5.5 60746b5c8b chore(mdns-relay): restart once to check the v1.9.1 net1 attach
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
No pod has attached net1 since cni-plugins-installer moved to
containernetworking/plugins v1.9.1 (#1142). Hestia's verify item: restart
mdns-relay and confirm a 10.2.30.x DHCP lease. Atlas has no cluster write,
so the restart goes through a pod-template annotation.

Refs: REN-722
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:02:31 +00:00
security-bot 97827f654e chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-06 04:41:00 +00:00
argus-bot (Paperclip service account) 44d0792b9f Merge pull request 'fix(openbao): RollingUpdate so the 2.7.1 pods roll out (REN-723)' (#1144) from atlas/ren-723-openbao-rolling into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 03:20:26 +00:00
atlas-botandClaude Opus 5.5 c62dae3b24 fix(openbao): roll the StatefulSet with RollingUpdate (REN-723)
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
#1132 staged OpenBao 2.7.1 in the StatefulSet template, but OnDelete
leaves the pods on 2.6.1 until someone deletes them, and no agent holds
pod delete in openbao. The static seal makes OnDelete unnecessary: the
readiness probe (bao status) passes only once a pod is unsealed, so the
controller replaces one pod at a time and waits for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:15:41 +00:00
argus-bot (Paperclip service account) 64042edc03 Merge pull request 'chore(deps): update openbao' (#1132) from renovate/openbao into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 03:11:39 +00:00
argus-bot (Paperclip service account) efd5191c6d Merge pull request 'fix(multus): cni-plugins-installer to containernetworking/plugins v1.9.1' (#1142) from atlas/ren-722-cni-plugins-installer-v1.9.1 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-10-06 00:21:21 +00:00
argus-bot (Paperclip service account) 0531f6451e Merge pull request 'chore(deps): update all non-major dependencies' (#1054) from renovate/all-minor-patch into master
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-06 00:14:29 +00:00
argus-bot (Paperclip service account) 65df76fd14 Merge pull request 'chore(deps): update home-assistant non-major' (#1129) from renovate/home-assistant-minor-patch into master 2026-10-06 00:09:39 +00:00
argus-bot (Paperclip service account) 3642f9dbb7 Merge pull request 'chore(deps): update multus cni' (#1131) from renovate/multus into master 2026-10-06 00:07:08 +00:00
argus-bot (Paperclip service account) a7bc9887f4 Merge pull request 'fix(jobs): force-recreate mosquitto and otbr bootstrap Jobs on spec change' (#1139) from atlas/ren-722-job-force-annotations into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 21:27:33 +00:00
argus-bot (Paperclip service account) a27ec53be0 Merge pull request 'chore(deps): update influxdb docker tag to v2.9.1' (#1130) from renovate/influxdb into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 21:15:22 +00:00
atlas-botandClaude Opus 5.5 67caa6e0d1 fix(multus): cni-plugins-installer to containernetworking/plugins v1.9.1
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
Point the installer at cni-plugins:2026-10-05, built from #1140
(plugins v1.5.1 -> v1.9.1). Version comments updated in the installer
and cni-dhcp-daemon.

Refs: REN-722
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:02:58 +00:00
argus-bot (Paperclip service account) 8cbd3f3d7a Merge pull request 'feat(influxdb): snapshot data PVC before 2.9 upgrade (REN-723)' (#1141) from atlas/ren-723-influxdb-snapshot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 18:55:03 +00:00
argus-bot (Paperclip service account) 4b786dac5e Merge pull request 'build(cni-plugins): containernetworking/plugins v1.5.1 -> v1.9.1' (#1140) from atlas/ren-722-cni-plugins-v1.9.1 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
2026-10-05 18:55:02 +00:00
atlas-botandClaude Opus 5.5 037163b59e feat(influxdb): snapshot the data PVC before the 2.9 upgrade (REN-723)
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
A VolumeSnapshot of influxdb-influxdb2 on ceph-block-snapclass, to be
merged and ready before homelab #1130 (2.7.4 -> 2.9.1) so the upgrade has
a restore point beyond `influxd downgrade`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:16:00 +00:00
atlas-botandClaude Opus 5.5 4d7c06c6cf docs(openbao): note no-downgrade rollout for chart minor bumps (REN-723)
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
Chart 0.30 moves the server 2.6.1 -> 2.7.1. Record the rollout rule
next to the pin: raft snapshot first, OnDelete one pod at a time, undo
by snapshot restore once a pod has run the new server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:14:27 +00:00
atlas-botandClaude Opus 5.5 2bfb3d12e2 fix(influxdb): keep API tokens unhashed on 2.9 (REN-723)
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
InfluxDB 2.9 hashes tokens on startup by default. The HA and Grafana
token Jobs read tokens back via `influx auth list`, which returns no
token once hashed, and a downgrade below 2.8 would erase all tokens.
Set INFLUXD_USE_HASHED_TOKENS=false so the 2.9.1 bump stays reversible
with `influxd downgrade 2.4`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:14:13 +00:00
atlas-botandClaude Opus 5.5 b6226b483b build(cni-plugins): containernetworking/plugins v1.5.1 -> v1.9.1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Refresh the plugins pin alongside multus v4.3.1 (#1131), as the
cni-plugins-installer.yaml comment asks. SHA256 cross-checked against
upstream's cni-plugins-linux-amd64-v1.9.1.tgz.sha256 asset. The tarball
still ships macvlan, static, host-local and dhcp.

Image only: CI builds cni-plugins:<date>. The DaemonSet tag bump follows
in a separate PR once the image exists.

Refs: REN-722
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:00:51 +00:00
atlas-botandClaude Opus 5.5 90a547546e fix(multus): re-vendor v4.3.1 thick manifest 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
Upstream deployments/multus-daemonset-thick.yml v4.2.4 -> v4.3.1 adds:
- ClusterRole rule: resource.k8s.io resourceclaims, resourceclaims/status,
  resourceslices; get, list (new DRA client)
- terminationGracePeriodSeconds 10 -> 30
- CRD shortName nad

Header comment updated to v4.3.1 (v4.3.1 also has an unsuffixed tag; we
keep -thick to match the manifest).

Refs: REN-722
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:00:29 +00:00
atlas-botandClaude Opus 5.5 bb0d32898b fix(jobs): force-recreate mosquitto and otbr bootstrap Jobs on spec change
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
Job pod templates are immutable. Without kustomize.toolkit.fluxcd.io/force,
an image bump (Renovate #1129, #1053, #1054) fails the server-side dry-run
and blocks the home-assistant and otbr Kustomizations. Same pattern as the
influxdb token Jobs.

The mosquitto Jobs exit 0 when their Secret exists. The otbr Job now also
exits 0 when the otbr Deployment is scaled to 0 (disabled), instead of
waiting for a pod that never comes and failing; Role gains get on
apps/deployments for that check.

Refs: REN-722
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:59:32 +00:00
argus-bot (Paperclip service account) aa3ee1d8c8 Merge pull request 'docs(paperclip): Claude connection needs a setup-token value' (#981) from docs/paperclip-claude-token into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 16:55:10 +00:00
argus-bot (Paperclip service account) 732158dcbc Merge pull request 'chore(ci): bump ci-claude image to 2.1.289' (#1134) from ci-image/claude-2.1.289 into master 2026-10-05 16:54:50 +00:00
argus-bot (Paperclip service account) 4caaae1e8c Merge pull request 'matrix-bridge: build.1791195981 (no repeated comments, REN-717)' (#1138) from atlas/ren-717-matrix-bridge-1791195981 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 13:05:45 +00:00
atlas-botandClaude Opus 5.5 7e18224053 matrix-bridge: build.1791195981 (no repeated comments, REN-717)
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:04:38 +00:00
argus-bot (Paperclip service account) 9352014be3 Merge pull request 'fix(paperclip): add CAP_KILL so tini can forward SIGTERM (REN-702)' (#1137) from atlas/ren-702-paperclip-cap-kill 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-10-05 07:18:35 +00:00
atlas-botandClaude Opus 5.5 9824e10e0a fix(paperclip): add CAP_KILL so tini can forward SIGTERM (REN-702)
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
tini (PID 1, root) cannot signal node (uid 1000) without CAP_KILL, exits
with EPERM on every rollout, and the server is killed without its
graceful run drain. In-flight runs are orphaned instead of being
interrupted and retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:10:32 +00:00
argus-bot (Paperclip service account) da1e6fedf0 Merge pull request 'feat(matrix): roll matrix-bridge to build.1791179547 (REN-691)' (#1136) from atlas/ren-691-matrix-bridge-1791179547 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-05 05:55:27 +00:00
atlas-botandClaude Opus 5.5 a975f4e8f6 feat(matrix): roll matrix-bridge to build.1791179547 (REN-691)
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
One Matrix room per card labelled 'matrix' (REN-668). Sets
PAPERCLIP_PUBLIC_URL for the card link in the room topic.
Rollback: build.1791177680 and drop the env var.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:54:02 +00:00
security-bot 5dab2f940e chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791176861@sha256:2f5535f14daf7a30f68f472ad638366260586771524a0fb28c53e62101128234
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-05 05:27:11 +00:00
argus-bot (Paperclip service account) f187c31a10 Merge pull request 'feat(matrix): roll matrix-bridge to build.1791177680 (REN-685)' (#1135) from atlas/ren-685-matrix-bridge-1791177680 into master 2026-10-05 05:24:56 +00:00
atlas-botandClaude Opus 5.5 e56f920079 feat(matrix): roll matrix-bridge to build.1791177680 (REN-685)
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
Route agent comments on author account only, from konairius/matrix-bridge#5
(9a83e70, REN-668). No env, secret, port or network change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:23:08 +00:00
claude-bot 5ad4c69676 chore(ci): bump ci-claude image to 2.1.289
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-10-05 05:11:33 +00:00
argus-bot (Paperclip service account) 94a2eac559 Merge pull request 'chore(deps): update ci non-major' (#1126) from renovate/ci-minor-patch into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-agent-runtime Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
2026-10-05 05:02:24 +00:00
renovate-bot 1784153656 chore(deps): update openbao
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
2026-10-05 02:01:36 +00:00
renovate-bot b32a5e0747 chore(deps): update multus cni
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
2026-10-05 02:01:34 +00:00
renovate-bot 8b0aaef728 chore(deps): update influxdb docker tag to v2.9.1
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
2026-10-05 02:01:32 +00:00
renovate-bot e1f0aa021f chore(deps): update home-assistant non-major
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
2026-10-05 02:01:31 +00:00
renovate-bot 68c0978660 chore(deps): update all non-major dependencies
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
2026-10-05 02:01:26 +00:00
renovate-bot ed272266fc chore(deps): update ci non-major
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
2026-10-05 02:01:23 +00:00
argus-bot (Paperclip service account) f930971102 Merge pull request 'cilium: chart-native metrics Service, attended helm upgrade (REN-166)' (#1032) from atlas/ren-166-cilium-verdict-metric 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
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
2026-10-05 00:01:56 +00:00
argus-bot (Paperclip service account) a3e0a3d5c3 Merge pull request 'feat(matrix): roll matrix-bridge to build.1791152484 (REN-671)' (#1125) from atlas/ren-671-matrix-bridge-1791152484 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 22:24:44 +00:00
atlas-botandClaude Opus 5.5 3096ad3d80 feat(matrix): roll matrix-bridge to build.1791152484 (REN-671)
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
Housekeeping filter from konairius/matrix-bridge#4 (f1ad2ea5, REN-668).
No env, secret, port or network change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:23:14 +00:00
argus-bot (Paperclip service account) 091fc33f86 Merge pull request 'feat(matrix): publish element, matrix and account.matrix publicly (REN-663)' (#1124) from atlas/ren-663-matrix-public into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 22:07:06 +00:00
atlas-botandClaude Opus 5.5 e579a8f6d3 feat(matrix): publish element, matrix and account.matrix publicly (REN-663)
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
The three Matrix Ingresses had no dns.renner.uno/public label, so
netbird-service-sync never created a NetBird proxy service for them and the
public proxy rejects their SNI (tlsv1 alert internal error). Label them like
audiobooks/immich and add Gatus checks for the three hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:01:22 +00:00
argus-bot (Paperclip service account) f1e987bdf5 Merge pull request 'feat(matrix): deploy matrix-bridge build.1791062345 (REN-587)' (#1123) from atlas/ren-587-matrix-bridge into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 21:46:21 +00:00
atlas-botandClaude Opus 5.5 b052fc2576 feat(matrix): generate the appservice tokens in-cluster (REN-587)
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
as_token and hs_token come from an ESO Password generator (CreatedOnce)
instead of OpenBao, so no one has to seed them by hand. Synapse mounts
only the generated Secret and no longer depends on the OpenBao path;
OpenBao homelab/apps/matrix-bridge now holds only paperclip_api_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:45:07 +00:00
atlas-botandClaude Opus 5.5 9093b52e00 feat(matrix): deploy matrix-bridge build.1791062345 as a Synapse appservice (REN-587)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Adds the matrix-bridge Deployment, Service and ExternalSecret in the
matrix namespace, loads its appservice registration in Synapse, and opens
the bridge -> Paperclip API path (:3100) in both namespaces' policies.
Tokens and the board key come from OpenBao homelab/apps/matrix-bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:41:10 +00:00
argus-bot (Paperclip service account) c384a052a9 Merge pull request 'fix(authentik): allow authorization_code for matrix-mas (REN-567)' (#1122) from atlas/ren-567-authentik-grant-types into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 21:20:51 +00:00
atlas-botandClaude Opus 5.5 e2e45b2b86 fix(authentik): allow authorization_code for matrix-mas (REN-567)
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
Authentik 2026.5 checks each authorize against the provider's
grant_types, and a provider created by a blueprint starts with an empty
list. Every Matrix login through Authentik failed with invalid_request
(log: "Invalid grant_type for provider", grant_type=authorization_code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:19:26 +00:00
argus-bot (Paperclip service account) 564e6abcaa Merge pull request 'feat(matrix): MAS with Authentik passkey login and QR sign-in (REN-567)' (#1121) from atlas/ren-567-mas into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 20:51:03 +00:00
atlas-botandClaude Opus 5.5 bfc3bbc190 feat(matrix): Element Web QR sign-in on the welcome page (REN-567)
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 wants QR sign-in from the desktop app as well. Linking a phone from
a signed-in Element (Link new device) only needs the server side from the
previous commit (MSC4108 + MAS device-code grant). This flag also lets a
new browser sign in by QR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:11:32 +00:00
atlas-botandClaude Opus 5.5 b17001d6da feat(matrix): MAS with Authentik passkey login and QR sign-in (REN-567)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Element X signs in only with a password or through MAS. Put MAS in front
of Synapse with Authentik (passkey) as the upstream provider, and enable
the QR rendezvous (MSC4108/MSC4388).

- apps/matrix/mas.yaml: MAS 1.26.0, `mas` database in mx-pg (CNPG
  Database), account.matrix.renner.uno.
- synapse.yaml: matrix_authentication_service, password_config off; a
  syn2mas init container moves the existing accounts and sessions once
  (marker on synapse-data).
- Drop the owner-account Job; MAS owns accounts now.
- Authentik: public PKCE client `matrix-mas`, passkey flows, only the
  Matrix Users group (k.renner).
- matrix-ns: MAS egress to Traefik :8443 (auth.renner.uno) only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:08:04 +00:00
argus-bot (Paperclip service account) c96f8d0282 Merge pull request 'feat(matrix): Synapse homeserver + Element Web, private, no federation (REN-567)' (#1112) from atlas/ren-567-matrix into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 19:43:36 +00:00
argus-bot (Paperclip service account) daa6f810a0 Merge pull request 'network-policies: paperclip agents → edge-1 SSH 10.2.30.108:22 (REN-627)' (#1120) from atlas/ren-627-edge1-ssh-egress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
2026-10-04 15:24:30 +00:00
atlas-botandClaude Opus 5.5 d04546382d network-policies: let paperclip agents reach edge-1 SSH on 10.2.30.108:22 (REN-627)
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
Scoped access for the zwave-js restart (REN-628): forced-command user
atlas-zwave on edge-1. One /32, TCP 22 only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:19:06 +00:00
argus-bot (Paperclip service account) 573bd670d5 Merge pull request 'monitoring: GET-only Loki read gateway for the paperclip read SA (REN-621)' (#1119) from atlas/ren-621-loki-read-gateway into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 12:22:00 +00:00
atlas-botandClaude Opus 5.5 fe2e163b6a monitoring: GET-only Loki read gateway for the paperclip read SA (REN-621)
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
Loki runs SingleBinary with auth disabled and answers GET on
/ingester/shutdown and /flush, so an RBAC `get` on services/proxy for
loki:3100 would not be read-only. Put an nginx allow-list in front of it
(query, query_range, labels, label/<name>/values, series; GET only;
upstream URI rebuilt per location) and grant the paperclip read SA
`get` on that Service only. Owner decision: REN-622 (option A2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:20:27 +00:00
argus-bot (Paperclip service account) f7c143d4a3 Merge pull request 'paperclip: raise pclp-pg memory to 1Gi/2Gi (REN-603)' (#1118) from atlas/ren-603-pclp-pg-memory into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-04 05:05:51 +00:00
atlas-botandClaude Opus 5.5 1cfd529d5d paperclip: raise pclp-pg memory to 1Gi/2Gi (REN-603)
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
The primary pclp-pg-2 was OOMKilled 6 times on 3-4 Oct at the 1Gi
limit. Each kill drops Paperclip's DB and fails in-flight agent runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:04:33 +00:00
argus-bot (Paperclip service account) d9c698609d Merge pull request 'review-bot: GITEA_VERIFY_REPO to konairius/shelly-manager (REN-528)' (#1106) from atlas/ren-528-review-bot-verify-repo 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-10-04 00:10:55 +00:00
argus-bot (Paperclip service account) a201100799 Merge pull request 'cilium: enable Hubble relay for paperclip (REN-487)' (#1102) from atlas/ren-487-hubble-relay into master 2026-10-04 00:07:02 +00:00
security-bot 0b1df0af40 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791063392@sha256:44887a2a95b567ea6847609662964c3056b9721c924f943770a550570cabd9b4
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-03 21:57:02 +00:00
argus-bot (Paperclip service account) 77c8566c5b Merge pull request 'gitea: keep volumeName on data-gitea-0 so the 60Gi apply passes (REN-583, fixes #1116)' (#1117) from atlas/ren-583-gitea-pvc-volumename into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-03 21:30:32 +00:00
atlas-botandClaude Opus 5.5 d2ee7a097c gitea: keep volumeName on the data-gitea-0 PVC so the 60Gi apply passes (REN-583)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
#1116 left spec.volumeName out of pvc.yaml. kustomize-controller has owned
that field since the namespace move pre-created the claim (eb23d3ac), so
server-side apply tried to clear it and the API server rejected the claim
as an immutable-spec change. The gitea Kustomization is Ready=False and the
claim stays at 40Gi. Pin volumeName to the live value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:29:21 +00:00
argus-bot (Paperclip service account) 4de7b0c49b Merge pull request 'gitea: grow data volume 40Gi -> 60Gi via Flux-owned PVC, add low-space alert (REN-583)' (#1116) from atlas/ren-583-gitea-volume-60gi into master 2026-10-03 21:26:21 +00:00
atlas-botandClaude Opus 5.5 433719bfc0 gitea: grow data volume 40Gi -> 60Gi via a Flux-owned PVC, add low-space alert (REN-583)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
The volume filled on REN-580. volumeClaimTemplates is immutable, so own the
live claim data-gitea-0 in apps/gitea/pvc.yaml (prune disabled) and let Flux
server-side apply grow it; ceph-block expands online. Add GiteaDataVolumeLow
(<25% free for 15m) with a promtool suite and two mutation controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:16:14 +00:00
argus-bot (Paperclip service account) b5f54ad069 Merge pull request 'paperclip: resume Claude sessions across runs with a managed AI home (REN-563)' (#1115) from atlas/ren-563-claude-session-resume into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
2026-10-03 20:43:50 +00:00
atlas-botandClaude Opus 5.5 8d894e466e paperclip: resume Claude sessions across runs with a managed AI home (REN-563)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Stable session fingerprint for the per-run managed AI home paths, plus a
persistent CLAUDE_CONFIG_DIR/projects so the resumed session finds its
transcript. Local runs only. Fix-forward of #1111 (reverted in #1113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:41:11 +00:00
security-bot 7a9f42a728 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791058212@sha256:bdb6933fe5d1c790550705867b93900c9e49df84cd1aa253305f5e8692d5292d
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-03 20:27:02 +00:00
argus-bot (Paperclip service account) a0ed9c0d17 Merge pull request 'Revert #1111: ACPX resume fails because Claude transcript is per-run (REN-563)' (#1113) from atlas/ren-563-revert-1111 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 was successful
2026-10-03 20:09:33 +00:00
atlas-botandClaude Opus 5.5 f68ac16137 Revert "paperclip: keep per-run managed AI home out of ACPX session fingerprint (REN-563)"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
This reverts #1111 (3f3305cf). With the fingerprint stable, the ACPX engine
now resumes the previous Claude session, but the Claude transcript lives in
the per-run CLAUDE_CONFIG_DIR, which the server deletes after each run. acpx
uses resumePolicy same-session-only for persistent sessions, so the turn
fails ("Persistent ACP session ... could not be resumed: Resource not
found") instead of falling back to a fresh session (run 12a2a911).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:08:28 +00:00
security-bot a1e1fee9e0 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791054619@sha256:df5d4ba55c8f0dc020ffb4faf87194b3e1b1df3076b4fde14bae20c860af9500
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-03 19:27:01 +00:00
atlas-botandClaude Opus 5.5 04bda0d7c2 feat(matrix): Synapse homeserver + Element Web, private, no federation (REN-567)
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
New `matrix` namespace and Flux KS: Synapse v1.162.0 on a CNPG Postgres
(mx-pg, Barman to Hetzner), media store + signing key on synapse-data
(Velero matrix-daily), Element Web locked to matrix.renner.uno, and an
idempotent Job that creates the one owner account from OpenBao
apps/matrix. Federation off, registration and guests closed, LAN-only
ingress, egress limited to the in-cluster DB and the push gateway.
Appservice slot left empty for REN-565 Part 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:15:11 +00:00
argus-bot (Paperclip service account) 3f3305cf01 Merge pull request 'paperclip: keep per-run managed AI home out of ACPX session fingerprint (REN-563)' (#1111) from atlas/ren-563-acpx-session-resume 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-10-03 19:09:54 +00:00
atlas-botandClaude Opus 5.5 01ed3ca20d paperclip: keep per-run managed AI home out of ACPX session fingerprint (REN-563)
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-10-03 19:06:50 +00:00
security-bot fd59c81bf4 chore(image-automation): bump gitea.renner.uno/konairius/paperclip:build.1791047004@sha256:36744031bb1b217c64dc72a79c49ecdf021e25961e757d26afedfbfb494a9860
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-03 17:27:01 +00:00
security-bot 9667c76b4c chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1791047765
Updates managed by image-automation-controller.
2026-10-03 17:21:19 +00:00
argus-bot (Paperclip service account) 4b11802e86 Merge pull request 'paperclip: rotate agent-argus SA token Secret via rename to -v3 (REN-544)' (#1110) from atlas/ren-544-argus-token-v3 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-03 17:10:00 +00:00
atlas-botandClaude Opus 5.5 ccb755ba38 paperclip: rotate agent-argus SA token Secret via rename to -v3 (REN-544)
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The -v2 token leaked into Argus run d2eede8a (REN-542). Renaming makes the
token controller mint a new token; Flux prunes the old Secret, which
invalidates the leaked token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:08:19 +00:00
argus-bot (Paperclip service account) 997e11ce19 Merge pull request 'fix(paperclip): rotate agent-atlas SA token Secret to -v3 (REN-539)' (#1109) from atlas/ren-539-atlas-token-v3 into master 2026-10-03 17:04:06 +00:00
argus-bot (Paperclip service account) f12193624e Merge pull request 'feat(paperclip): keep Claude auto memory in AGENT_HOME (REN-541)' (#1108) from atlas/ren-541-claude-auto-memory into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-paperclip Pipeline was successful
2026-10-03 17:02:56 +00:00
argus-bot (Paperclip service account) d6ce8bdf1f Merge pull request 'paperclip: rotate agent-hestia SA token Secret via rename to -v3 (REN-540)' (#1107) from atlas/ren-540-hestia-token-v3 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-03 16:59:22 +00:00
atlas-botandClaude Opus 5.5 856fc28866 fix(paperclip): rotate agent-atlas SA token Secret to -v3 (REN-539)
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The agent-atlas-token-v2 token leaked in an Atlas run log (REN-536).
Rename the Secret to -v3 so Flux prunes -v2 and the token controller
issues a new token; update the agent-token-read resourceNames entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:48:32 +00:00
atlas-botandClaude Opus 5.5 5a77a30c18 feat(paperclip): keep Claude auto memory in AGENT_HOME (REN-541)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Claude auto memory defaults to $CLAUDE_CONFIG_DIR/projects/<p>/memory.
Paperclip makes CLAUDE_CONFIG_DIR fresh per run and deletes it, so every
memory is lost. New image patch: the ACPX engine writes
autoMemoryDirectory=<AGENT_HOME>/memory into the per-run
.claude/settings.local.json. AGENT_HOME is restored and saved around each
run, local and in k8s pods. Not part of the session fingerprint, so
resume is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:46:18 +00:00
atlas-botandClaude Opus 5.5 f835f40eb7 paperclip: rotate agent-hestia SA token Secret via rename to -v3 (REN-540)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
The token leaked into Hestia run 13bd8a68 (REN-535). Renaming makes the
token controller mint a new token; Flux prunes the old Secret, which
invalidates the leaked token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:39:31 +00:00
atlas-botandClaude Opus 5.5 427b98948e review-bot: verify against konairius/shelly-manager after repo move (REN-528)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
GITEA_VERIFY_REPO still named k.renner/shelly-manager, which now
answers 307 to the new path. Point it at the new path directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:34:20 +00:00
argus-bot (Paperclip service account) e553c37cb3 Merge pull request 'feat(monitoring): blackbox-http probe for shelly-manager /health (REN-533)' (#1105) from atlas/ren-533-shelly-manager-probe into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-03 16:30:50 +00:00
atlas-botandClaude Opus 5.5 6649e5b560 feat(monitoring): blackbox-http probe for shelly-manager /health (REN-533)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
In-cluster app-health target via the ClusterIP Service, so a dead app
behind the live shelly-manager-gate outpost no longer reads green.
Covered by the existing BlackboxProbeFailing rule; no CNP change needed
(home-assistant default-deny already admits the monitoring namespace).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:29:05 +00:00
security-bot 0d9d6b3c97 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1791038995
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-03 15:21:19 +00:00
argus-bot (Paperclip service account) 2d8191efdd Merge pull request 'paperclip: rotate agent-argus SA token Secret via rename (REN-525)' (#1104) from atlas/ren-525-rotate-argus-token into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-03 15:04:00 +00:00
atlas-botandClaude Opus 5.5 8f98d267c6 paperclip: rotate agent-argus SA token Secret via rename (REN-525)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
The token leaked into Argus run 373367ed (REN-523). Renaming makes the
token controller mint a new token; Flux prunes the old Secret, which
invalidates the legacy token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:46:26 +00:00
argus-bot (Paperclip service account) 080ef16b9d Merge pull request 'paperclip: rotate agent-hestia/hermes/atlas SA tokens via rename (REN-509)' (#1103) from atlas/ren-509-rotate-agent-tokens 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-10-03 06:39:03 +00:00
atlas-botandClaude Opus 5.5 a59810d351 paperclip: rotate agent-hestia/hermes/atlas SA token Secrets via rename (REN-509)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
The three tokens leaked into run logs (REN-483, REN-484, REN-507).
Renaming makes the token controller mint new tokens; Flux prunes the
old Secrets, which invalidates the legacy tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 06:36:55 +00:00
atlas-botandClaude Opus 5.5 dc669f9f06 hubble-relay CNP: limit node ingress to the :4222 probe 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
The host/remote-node rule had no toPorts, so any node or hostNetwork pod
could reach the relay's unauthenticated gRPC on :4245. Restrict it to the
kubelet gRPC probe port (chart deployment.yaml probes use port 4222).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:15:00 +00:00
atlas-botandClaude Opus 5.5 8a863fbbd7 monitoring: drop line-number citation of upstream ESO values.yaml (REN-487)
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
yaml-citations failed on external-secrets-podmonitor.yaml:35, which cited the
upstream chart's values.yaml by line. That file is not in the repo, so no
path#anchor form exists; name the keys instead. Comment-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:07:29 +00:00
atlas-botandClaude Opus 5.5 8f736eb217 cilium: enable Hubble relay, reachable from the paperclip pod only (REN-487)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
Hubble is already on in every agent; the relay gives one gRPC endpoint so
`hubble observe -n paperclip-renner --verdict DROPPED` runs without exec
into kube-system. The chart renders no change to cilium-config or the agent
DaemonSet, so the reloader does not roll the CNI.

The relay has no client auth: a new CNP admits only the paperclip pod (and
nodes for probes), and paperclip-egress gains the matching :4245 rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:37:19 +00:00
argus-bot (Paperclip service account) bb94b49b6b Merge pull request 'agent-runtime-go: add govulncheck (REN-488)' (#1101) from atlas/ren-488-govulncheck into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/build-agent-runtime Pipeline was successful
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-10-02 21:00:31 +00:00
atlas-botandClaude Opus 5.5 54d135c56a agent-runtime-go: add govulncheck v1.8.0 (REN-488)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Built static in the go stage with go install (module checked against
sum.golang.org); added to the default-PATH check and the README table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:58:58 +00:00
argus-bot (Paperclip service account) 3013d04143 Merge pull request 'network-policies: open duty-list targets for agent sandbox pods (REN-487)' (#1100) from atlas/ren-487-agent-pod-egress into master 2026-10-02 20:51:49 +00:00
atlas-botandClaude Opus 5.5 ded86ac6ba network-policies: open duty-list targets for agent sandbox pods (REN-487)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
- apiserver CNP selects paperclip.io/role=agent: the plugin writes the run id
  into paperclip.io/agent-id, so the agent-id selector never matched (REN-428).
- agent pods reach the wiki API (egress here, ingress in ai-ns.yaml).
- FQDN allow for api.github.com, GitHub release-asset hosts, vuln.go.dev,
  uptime.renner.uno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:02 +00:00
security-bot bf70ef84c7 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.5
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-02 12:40:24 +00:00
argus-bot (Paperclip service account) a0151c9a1d Merge pull request 'immich: retire orphaned immich-library PVC from git (REN-453)' (#1099) from atlas/ren-453-retire-immich-library into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-02 04:16:27 +00:00
atlas-botandClaude Opus 5.5 bff1bec2d6 chore(immich): retire orphaned immich-library PVC from git (REN-453)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
immich-server mounts immich-library-bulk + immich-thumbs since 4cc1b643;
immich-library is mounted by no pod but is still backed up (~250 GiB)
and restored by the restore test. The newest immich-daily backup
(immich-daily-20261001051507) holds immich-library-bulk and immich-thumbs.

Drop the PVC manifest and update comments that cited it. The PVC keeps
prune: disabled, so this merge does not delete the live volume; that is
a separate, owner-approved step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:18:03 +00:00
argus-bot (Paperclip service account) 125f8ec7ef Merge pull request 'velero restore-test: abort a restore before it fills Ceph (REN-451)' (#1098) from atlas/ren-451-restore-abort 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
2026-10-01 17:15:40 +00:00
atlas-botandClaude Opus 5.5 518e72b83f velero restore-test: abort a restore before it fills Ceph (REN-451)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
On 2026-10-01 the immich restore took the fullest OSD from 74% to 95%;
the Job died at its deadline, the Restore kept writing unwatched and
Ceph went OSD_FULL at 15:10.

- Mid-run guard: each restore poll reads the fullest OSD; at 83%
  (OSD_ABORT_PCT, below nearfull) or after 4 polls with no reading,
  cancel the DataDownloads, tear the restore down, stop the sweep (FAIL).
- A restore timeout cancels the same way.
- TERM/EXIT trap cancels the restore in flight; grace period 600 s.
- Time budget: no restore starts whose timeout + 900 s reserve passes
  the Job deadline (SKIP).
- RBAC: get/list/patch on velero.io datadownloads only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:08:13 +00:00
argus-bot (Paperclip service account) a6c2ca5c6a Merge pull request 'velero restore-test: never restore, clone or count CNPG volumes (REN-451)' (#1097) from atlas/ren-451-restore-test-skip-cnpg into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-01 16:55:09 +00:00
atlas-botandClaude Opus 5.5 835bc3e97a velero restore-test: never restore, clone or count CNPG volumes (REN-451)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
The Restore took every PVC in the backup, and the parity probe then
snapshotted and cloned each restored PVC name in the live namespace. A
backup that holds a CNPG PVC would give two extra copies of the
database, one of them next to production.

- Restore: labelSelector cnpg.io/cluster DoesNotExist.
- Parity loop: only restored PVCs without cnpg.io/cluster.
- Capacity gate: leave CNPG DataUploads out, and add the source-side
  probe clone (size-3 pool) for every PVC the run will probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:53:04 +00:00
k.renner 9ea9cd67c5 Merge pull request 'zigbee2mqtt: apply WS90 reporting config via external converter' (#1092) from z2m-ws90-converter into master
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1092
2026-10-01 10:40:53 +00:00
argus-bot (Paperclip service account) 8ce6922020 Merge pull request 'velero restore-test: skip a restore projected past 80% OSD usage (REN-442)' (#1093) from atlas/ren-442-restore-capacity-gate into master 2026-10-01 10:31:00 +00:00
atlas-botandClaude Opus 5.5 b1d023fc1d velero restore-test: skip a restore projected past 80% OSD usage
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
Before each schedule's restore, project its raw size (DataUpload
totalBytes x pool replication/EC factor, spread over all OSDs) onto
current OSD usage from Prometheus. If the fullest OSD would go above
OSD_MAX_PCT (80), skip the schedule and report SKIP with the projected %.
Skips are listed in the summary and do not fail the Job; a projection
that cannot be made fails that schedule without restoring.

Allows the restore-test pod to reach Prometheus :9090 and notes the gate
in RECOVERY.md. No alert rule changes. Refs REN-441, REN-442.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:25:09 +00:00
claude-botandClaude Opus 5.5 025423b342 zigbee2mqtt: apply WS90 reporting config via external converter
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
The WS90 reporting configuration (zigbee2mqtt-ws90-reporting.md) only
lived on the device and in database.db; every interview or
device/configure reset it to the converter defaults (2026-09-24..28,
zero frames recurred).

New external converter extends the built-in WS90 definition with a
configure step that sets the documented values. Its version
(ws90-reporting-1) makes Z2M reconfigure the device once after load.
seed-data copies converters from a ConfigMap into
/app/data/external_converters on every start; not mounted because Z2M
writes a temp copy into that directory while loading.

Tested in the running pod against a mocked device: the definition is
picked over the built-in one, and configure ends with the ten
documented reporting values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:01:21 +02:00
k.renner 05dfabb528 Merge pull request 'zigbee2mqtt: raise memory limit to 1Gi' (#1074) from z2m-memory-limit into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1074
2026-10-01 09:53:24 +00:00
argus-bot (Paperclip service account) d3f89b1477 Merge pull request 'monitoring: stop wear alerts on EPHEMERAL NVMe drives (REN-419)' (#1088) from atlas/ren-419-ephemeral-wear-alert into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-01 09:19:18 +00:00
atlas-botandClaude Opus 5.5 42e67219e0 monitoring: feed disk-wear case A at 5m so the 6h for completes (REN-438)
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
Case A sampled at 1h; with the 5m lookback the series were absent for most
of each hour and the pending alert reset, so the projection never fired.
Now 5m samples (1m exceeds promtool's sample limit on the 7d rate), same
0.04272 %/day and 140-day summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:07:58 +00:00
argus-bot (Paperclip service account) 6bf9541625 Merge pull request 'docs(monitoring): record the ESO scrape trust boundary and real grant scope (REN-100)' (#998) from atlas/ren-103-scrape-doc-gaps into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
2026-10-01 09:03:17 +00:00
atlas-botandClaude Opus 5.5 18629fd496 monitoring: stop wear alerts on EPHEMERAL NVMe drives (REN-419)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
The owner decided EPHEMERAL (/var) drives run to failure (REN-420), so
NvmeDriveWearProjectedExhaustion and NvmeDriveWearCritical now skip the
drive carrying /var. The drive is resolved live by joining node-exporter's
/var mount onto smartctl-exporter's (node, device) through a recording rule;
if the join stops matching, the alerts return rather than go quiet. Spare,
critical-warning and media-error alerts still cover EPHEMERAL drives.

Adds a promtool suite and four negative controls for the exclusion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 05:40:32 +00:00
argus-bot (Paperclip service account) 7ae8276dea Merge pull request 'paperclip: per-agent kubectl ServiceAccounts + apiserver egress CNP (REN-414)' (#1080) from atlas/ren-414-agent-kubectl-sa 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
2026-09-30 23:19:33 +00:00
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
claude-botandClaude Opus 5.5 3e1599bcad zigbee2mqtt: raise memory limit to 1Gi
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
Node derives the V8 heap from the container limit: 512Mi gave a
259 MB heap, and Z2M crashed with "JavaScript heap out of memory"
(exit 134) at 2026-09-28 17:56Z and 2026-09-29 07:36Z. Request
raised to 256Mi to cover the measured 232Mi working set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 07:30:17 +02: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
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
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 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
atlas-botandClaude Opus 5.5 df5e425b7c fix(cilium): drop cilium_policy_verdict_total, not a cilium 1.20.2 metric (REN-166)
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
Argus's review of PR #1032: the name exists in neither pkg/ nor the metrics
docs for 1.20.2, and the agent skips unknown --metrics names silently. Zeus
dropped it from scope. metricsService, the Service delete and the new
selector stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:08:42 +00:00
atlas-botandClaude Opus 5.5 c7e61e7dce feat(cilium): enable cilium_policy_verdict_total, chart owns the metrics Service (REN-166)
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
- prometheus.metrics: +cilium_policy_verdict_total (opt-in; per-direction
  policy verdict attribution on top of cilium_drop_count_total).
- prometheus.metricsService: true, so the chart renders the headless
  `cilium-agent` metrics Service.
- Drop the hand-declared `cilium-agent-metrics` Service from
  cilium-servicemonitor.yaml and point the ServiceMonitor at the chart's
  Service, so the endpoints are scraped once, not twice.

This is a values change: helm-controller upgrades the CNI release, the
cilium-config `metrics` key changes, and the reloader annotation rolls the
agents node by node. Merge only inside the window Zeus set on REN-166.

Revert: git revert this commit; Flux restores the old Service and selector
and rolls the agents back the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:03:58 +00:00
51afb386bc docs(netpol): correct the monitoring-grant count in external-secrets-ns
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 previous wording claimed home-assistant-ns.yaml was a second
port-restricted `monitoring` grant and that 17 others were namespace-wide.
Both numbers were wrong. Counted per ingress list item over
kubernetes/infrastructure/network-policies at master:

  20 `monitoring` grants across 19 files
  (influxdb-ns.yaml carries two — one whole-namespace, one Grafana-pod)
  exactly one of them — this rule — carries a `toPorts`

home-assistant-ns.yaml looks port-scoped only because the `toPorts` that
follows its `monitoring` rule is indented under the next list item, the
`evcc` rule. Its `monitoring` grant is namespace-wide.

Comment-only. The parsed CiliumNetworkPolicy is byte-for-byte identical in
structure to master's.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:39:23 +00:00
b4e56e2d1f docs(monitoring): record the ESO scrape's trust boundary and real grant scope (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
Comment-only. No manifest field changes, so `kustomize build` output is
byte-identical before and after.

Two gaps in what REN-103's merged scrape says about itself, both raised in
review of PR #994/#996:

  * The metrics endpoint is plain HTTP with no authentication. That is chart
    2.11.0's default (`metrics.listen.secure.enabled` and
    `metrics.listen.auth.enabled` both false, values.yaml:365-369) and this
    repo overrides neither, so what actually bounds the exposure is the
    CiliumNetworkPolicy, not the endpoint. A reader adding `tlsConfig` to the
    PodMonitor would have broken the scrape; say so in the file.

  * The network policy comment implied the grant reaches the controller. It
    does not: the CNP's `endpointSelector` is {}, so monitoring→:8080 is
    admitted for all three ESO deployments, which all serve metrics on that
    port. Only the controller is scraped, because the PodMonitor's selector is
    exact on the unsuffixed name. Reachable-but-unvisited is the accurate
    description and the one a future reader needs.

Also corrected two counts in the same comment. The port-scoping is not unique
in this directory — home-assistant-ns.yaml does it too, so 17 of the other 18
`monitoring` grants are namespace-wide, not all 18. And there is no single
"health port": the controller's is 8082, the webhook's and cert-controller's
are 8081 (values.yaml:501-504, 564-570, 882-885).

Revert: `git revert` this commit. It restores the previous comment text and
touches no applied field.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 00:26:01 +00:00
k.renner 0ec61a3e61 Merge branch 'master' into docs/paperclip-claude-token
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-25 20:07:04 +00:00
claude-botandClaude Opus 5.5 7b66a8f4cf docs(paperclip): Claude connection needs a setup-token value
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The local-login connection stores only the ~8h OAuth access token and
Paperclip never refreshes Anthropic credentials (subscription values
are injected read-only as CLAUDE_CODE_OAUTH_TOKEN). It expired 8h after
the login and every run failed with 'terminal access failure'.
Replacing the secret value with a claude setup-token token fixed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 21:59:31 +02:00
155 changed files with 6018 additions and 506 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.289
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+4 -2
View File
@@ -30,8 +30,10 @@ when:
steps:
- name: build-agent-runtime
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
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:
@@ -56,7 +58,7 @@ steps:
for VARIANT in go ops; do
/kaniko/executor \
--cleanup \
--context "agent-runtime-$VARIANT" \
--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" \
+11 -11
View File
@@ -43,7 +43,7 @@ when:
steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/ansible
depends_on: []
# Only rebuild when THIS image's source (or the pipeline file) changed, or
@@ -71,7 +71,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/k8s
depends_on: []
when:
@@ -95,7 +95,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/vmtest
depends_on: []
when:
@@ -119,7 +119,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/tofu
depends_on: []
when:
@@ -150,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: secret-rotator
depends_on: []
when:
@@ -201,7 +201,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-jq
depends_on: []
when:
@@ -254,7 +254,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cni-plugins
depends_on: []
when:
@@ -296,7 +296,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/alpine-openssl
depends_on: []
when:
@@ -333,7 +333,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/heating-can
depends_on: []
when:
@@ -360,7 +360,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/netbird-service-sync
depends_on: []
when:
@@ -386,7 +386,7 @@ steps:
# 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
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/bird
depends_on: []
when:
+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.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
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
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
image: woodpeckerci/woodpecker-cli:v3.18.1-alpine@sha256:7eac3c4254befc1ba1e65269c3304c370b6032530b7386fc0c7648378fdd0516
commands:
- |
set -eu
+3 -3
View File
@@ -35,18 +35,18 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
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.280
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
commands:
- sh scripts/ci/test-yaml-citations.sh
+1 -1
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.1
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
+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.289
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.289
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.289
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.289
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.289
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+5 -5
View File
@@ -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.289
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.289
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.289
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.289
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.289
pull: true
environment:
WIKI_BOT_TOKEN:
+6 -2
View File
@@ -54,11 +54,15 @@ Pod images for the Paperclip Kubernetes sandbox (REN-284). Each is the stock
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.
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-go/agent-runtime-claude:v1` | `gitea.renner.uno/konairius/agent-runtime-go` | Go toolchain, golangci-lint, govulncheck |
| `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
@@ -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"
}
}
+39 -1
View File
@@ -7,6 +7,13 @@
#
# Both binaries are static; they run on the Ubuntu 22.04 base unchanged.
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS go
# govulncheck (REN-488) has no upstream image: build it in the go stage, static.
# The version pins the module; go verifies it against sum.golang.org. Renovate
# does not track it (no regex manager here): bump by hand.
FROM go AS govulncheck
RUN CGO_ENABLED=0 GOBIN=/out go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS golangci-lint
# upstream publishes no `v1` tag, only git-<sha> tags. Renovate keeps the
@@ -15,6 +22,34 @@ FROM ghcr.io/paperclipai/agent-runtime-claude:git-38d8f371722b315d2fb3bbaa512518
COPY --from=go /usr/local/go /usr/local/go
COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
COPY --from=govulncheck /out/govulncheck /usr/local/bin/govulncheck
# 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.
@@ -27,4 +62,7 @@ ENV PATH=/usr/local/go/bin:$PATH \
GOTOOLCHAIN=local
# Base user (1000:1000), entrypoint (tini) and cmd (agent shim) are inherited.
RUN go version && golangci-lint version
# 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 govulncheck claude-agent-acp; do command -v "$t"; done'
+26 -3
View File
@@ -7,8 +7,8 @@
#
# 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/kubectl:v1.37.1@sha256:b7cab618e281b1ee7484e7b706a96e2135fbb6e072c2a573a7dab4e87d7f2385 AS kubectl
FROM ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 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
@@ -21,8 +21,31 @@ 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
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 alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
# workload that needs to read JSON from a shell script.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache \
ansible \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
+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
+4 -4
View File
@@ -28,10 +28,10 @@
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
# node architecture. Add a second stage if a different arch is ever needed.
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d AS fetch
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
ARG CNI_PLUGINS_VERSION=v1.5.1
ARG CNI_PLUGINS_SHA256=77baa2f669980a82255ffa2f2717de823992480271ee778aa51a9c60ae89ff9b
ARG CNI_PLUGINS_VERSION=v1.9.1
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
# busybox wget and tar are already in the base; nothing is installed here
# either. sha256sum -c fails the BUILD if the artifact does not match, which is
@@ -61,7 +61,7 @@ RUN set -eu; \
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
done
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Alpine rather than scratch: the installer needs a shell to copy the binaries
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.13-slim AS build
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.13-slim
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
ARG TALOS_VERSION=v1.13.10
ARG VELERO_VERSION=v1.16.0
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.13-slim
FROM python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
+87
View File
@@ -0,0 +1,87 @@
# 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`).
Claude auto memory is lost after every run (REN-541): it lives under
`CLAUDE_CONFIG_DIR`, which Paperclip makes fresh per run and deletes.
`ren-541-claude-auto-memory.patch` makes the ACPX engine write
`autoMemoryDirectory: <AGENT_HOME>/memory` into the per-run
`.claude/settings.local.json`. `AGENT_HOME` is restored before and saved after
every run, local and in k8s pods. The value is not part of the session
fingerprint, so resume is unchanged. No upstream PR yet; rename the patch to
`pr-<n>-…` once there is one.
Claude sessions never resume with a managed AI connection (REN-563): the
server gives every run a fresh temp home (`HOME`, `XDG_*`,
`CLAUDE_CONFIG_DIR`, …) and deletes it after the run.
`ren-563-acpx-claude-session-resume.patch` fixes both halves for local runs:
the ACPX engine hashes a fixed placeholder for those paths in the session
fingerprint (`adapterEnvHash`), as the server's own fingerprint already does,
and links `CLAUDE_CONFIG_DIR/projects` (the Claude transcripts) to
`<stateDir>/claude-projects`, which persists. Both must ship together: a
stable fingerprint without the transcript makes the resumed turn fail with
"Resource not found" (homelab#1111, reverted in #1113). Remote runs are
unchanged. No upstream PR yet.
A cancelled or failed k8s pod run blocks its card for good (REN-841). The
owner's next comment waits on `remote_cleanup`, because the server continues
only when every remote lease carries a provider termination receipt
(`explicit-native-continuation.ts`), and the Kubernetes plugin's
`onEnvironmentReleaseLease` / `onEnvironmentDestroyLease` returned `void`.
`ren-841-k8s-termination-receipt.patch` makes both return
`{ providerLeaseId, state: "destroyed" }`, but only after the Sandbox CR or
Job (and, on destroy, the pod) reads 404, polling for up to 20 s, below the
server's 30 s plugin RPC timeout. If that check times out or fails, the hook
returns no receipt, as before. No upstream PR yet.
## 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 every patch here is merged upstream
Renovate cannot watch an upstream PR, so this is a manual step. Once
upstream nightly contains all of them:
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
@@ -0,0 +1,118 @@
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
index a26f980..112e2eb 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
@@ -2544,6 +2544,37 @@ describe("shared ACPX engine runtime behavior", () => {
expect(note).toBeTruthy();
});
+ it("points Claude auto memory at the run's AGENT_HOME without changing the session fingerprint", async () => {
+ const root = await makeTempRoot();
+ const stateDir = path.join(root, "state");
+ const cwd = path.join(root, "worktree");
+ await fs.mkdir(cwd, { recursive: true });
+ const stableHome = path.join(root, "agent-home");
+ const runAgentHome = (runId: string) => path.join(root, "file-sync", "runs", runId, "live");
+ const readSettings = async () =>
+ JSON.parse(await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8")) as {
+ autoMemoryDirectory?: string;
+ permissions?: { additionalDirectories?: string[] };
+ };
+
+ const first = await runExecutor(
+ { agent: "claude", stateDir, cwd },
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("a"), agentHomeForPermissions: stableHome } } },
+ );
+ expect((await readSettings()).autoMemoryDirectory).toBe(path.join(runAgentHome("a"), "memory"));
+
+ const second = await runExecutor(
+ { agent: "claude", stateDir, cwd },
+ { context: { paperclipWorkspace: { cwd, agentHome: runAgentHome("b"), agentHomeForPermissions: stableHome } } },
+ );
+ const written = await readSettings();
+ expect(written.autoMemoryDirectory).toBe(path.join(runAgentHome("b"), "memory"));
+ expect(written.permissions?.additionalDirectories).toContain(stableHome);
+ expect(written.permissions?.additionalDirectories).not.toContain(runAgentHome("b"));
+ expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
+ expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
+ });
+
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index b1dc569..e5681b8 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -1558,6 +1558,7 @@ interface PaperclipClaudeSettingsResult {
additionalDirectories: string[];
defaultMode: string;
overrodeDontAsk: boolean;
+ autoMemoryDirectory: string | null;
}
function uniqueSorted(values: Array<string | null | undefined>): string[] {
@@ -1571,10 +1572,19 @@ function uniqueSorted(values: Array<string | null | undefined>): string[] {
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
// needs to talk to its own control plane.
+//
+// It also points Claude's auto memory at `<AGENT_HOME>/memory`. The default
+// location lives under CLAUDE_CONFIG_DIR, which is a per-run temp home, so
+// memories were lost after every run. AGENT_HOME is restored before and saved
+// after each run, locally and in remote sandboxes. Claude ignores
+// `autoMemoryDirectory` only in checked-in project settings, not local ones.
async function writePaperclipClaudeSettings(input: {
cwd: string;
stateDir: string;
agentHome: string;
+ // The run's actual AGENT_HOME (the in-sandbox path for remote runs). It may
+ // differ from `agentHome`, which stays stable for session identity.
+ runAgentHome: string;
companyId: string;
}): Promise<PaperclipClaudeSettingsResult> {
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
@@ -1630,7 +1640,14 @@ async function writePaperclipClaudeSettings(input: {
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
};
- const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
+ const autoMemoryDirectory = input.runAgentHome
+ ? path.posix.join(input.runAgentHome, "memory")
+ : null;
+ const next: Record<string, unknown> = {
+ ...existing,
+ permissions: nextPermissions,
+ ...(autoMemoryDirectory ? { autoMemoryDirectory } : {}),
+ };
await writeFileAtomically({
target: filePath,
contents: `${JSON.stringify(next, null, 2)}\n`,
@@ -1642,6 +1659,7 @@ async function writePaperclipClaudeSettings(input: {
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
overrodeDontAsk,
+ autoMemoryDirectory,
};
}
@@ -2064,12 +2082,18 @@ async function buildRuntime(input: {
// by the company root already granted in these settings; the process env
// still receives this run's actual AGENT_HOME.
agentHome: asString(workspaceContext.agentHomeForPermissions, agentHome),
+ // Auto memory follows the actual AGENT_HOME so it is saved with the run.
+ // It is not part of the session fingerprint: a moved memory directory
+ // must not invalidate a resumable conversation.
+ runAgentHome: agentHome,
companyId: agent.companyId,
});
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
- }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
+ }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)${
+ paperclipClaudeSettings.autoMemoryDirectory ? `, autoMemoryDirectory=${paperclipClaudeSettings.autoMemoryDirectory}` : ""
+ }).`,
);
} else if (acpxAgent === "codex") {
// Step 2 — codex-home.seed: the codex managed-home + skills preparation.
@@ -0,0 +1,128 @@
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
index 112e2eb..004baf0 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
@@ -1857,6 +1857,45 @@ describe("shared ACPX engine runtime behavior", () => {
expect(changed.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
});
+ it("keeps the per-run managed AI home out of session identity and keeps Claude transcripts", async () => {
+ const root = await makeTempRoot();
+ const stateDir = path.join(root, "state");
+ const config = { agentCommand: "node ./fake-acp.js", stateDir };
+ async function withManagedHome(home: string, claudeConfigDir = path.join(home, "provider")) {
+ return runExecutor({
+ ...config,
+ managedAiConnection: { identity: "grant-1:shared:abc" },
+ env: {
+ HOME: home,
+ XDG_CONFIG_HOME: path.join(home, "config"),
+ XDG_DATA_HOME: path.join(home, "data"),
+ CODEX_HOME: path.join(home, "provider"),
+ GROK_HOME: path.join(home, "provider"),
+ CLAUDE_CONFIG_DIR: claudeConfigDir,
+ },
+ }, { context: { taskId: "issue-1" } });
+ }
+ const fp = (run: Awaited<ReturnType<typeof runExecutor>>) => run.result.sessionParams?.configFingerprint;
+ const first = await withManagedHome(path.join(root, "paperclip-ai-a"));
+ const second = await withManagedHome(path.join(root, "paperclip-ai-b"));
+ const overridden = await withManagedHome(path.join(root, "paperclip-ai-c"), path.join(root, "custom-claude"));
+ expect(fp(first)).toBeTypeOf("string");
+ expect(fp(second)).toBe(fp(first));
+ expect(fp(overridden)).not.toBe(fp(first));
+
+ // The transcript dir of each managed home points at one dir in the state
+ // dir, so a resumed session finds its transcript after the home is deleted.
+ const transcripts = path.join(stateDir, "claude-projects");
+ expect(await fs.readlink(path.join(root, "paperclip-ai-a", "provider", "projects"))).toBe(transcripts);
+ expect(await fs.readlink(path.join(root, "paperclip-ai-b", "provider", "projects"))).toBe(transcripts);
+ await expect(fs.lstat(path.join(root, "custom-claude", "projects"))).rejects.toThrow();
+
+ // Without a managed AI connection the same paths are user config and still count.
+ const plainA = await runExecutor({ ...config, env: { HOME: path.join(root, "home-a") } }, { context: { taskId: "issue-1" } });
+ const plainB = await runExecutor({ ...config, env: { HOME: path.join(root, "home-b") } }, { context: { taskId: "issue-1" } });
+ expect(fp(plainB)).not.toBe(fp(plainA));
+ });
+
it("busts the session fingerprint when a stable configured PAPERCLIP_* value rotates", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index e5681b8..0fd486c 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -1663,6 +1663,26 @@ async function writePaperclipClaudeSettings(input: {
};
}
+// Claude keeps conversation transcripts in CLAUDE_CONFIG_DIR/projects. A
+// managed AI home is a per-run temp dir that the server deletes after the run,
+// so the next run could not resume ("Resource not found"). Point `projects` at
+// a directory in the agent's state dir, like ~/.claude/projects on a normal
+// install. The server's recursive cleanup removes the link, not its target.
+async function linkManagedClaudeTranscripts(input: {
+ claudeConfigDir: string;
+ stateDir: string;
+}): Promise<string> {
+ const target = path.join(input.stateDir, "claude-projects");
+ await fs.mkdir(input.claudeConfigDir, { recursive: true, mode: 0o700 });
+ await fs.mkdir(target, { recursive: true, mode: 0o700 });
+ const link = path.join(input.claudeConfigDir, "projects");
+ const current = await fs.readlink(link).catch(() => null);
+ if (current === target) return target;
+ await fs.rm(link, { recursive: true, force: true });
+ await fs.symlink(target, link, "dir");
+ return target;
+}
+
// Cross the CLI's staging seam for a runner-backed remote sandbox: ship the
// workspace (and, in PR 2, the per-adapter managed-home `assets`) into the
// sandbox and obtain the in-sandbox `workspaceRemoteDir` plus the non-null
@@ -2027,6 +2047,30 @@ async function buildRuntime(input: {
// are absent from tempKeysApplied and keep their compatibility protection.
if (!scratchKeys.has(key) || value !== scratch.dir) resolvedAdapterEnv[key] = value;
}
+ // A managed AI connection gets a fresh server-made temp home on every run
+ // (HOME, XDG_*, and the provider home under it). Hash a fixed placeholder for
+ // those paths so the session can resume; the server's own fingerprint does
+ // the same (managedAiSessionFingerprintConfig). Values that differ from the
+ // managed layout are real overrides and stay in the hash. A Claude session
+ // can only resume if its transcript survives the run, so this pairs with
+ // linkManagedClaudeTranscripts below. Remote runs keep their transcript in
+ // the sandbox and are left as they are.
+ const managedHome = config.managedAiConnection && !executionTargetIsRemote ? resolvedAdapterEnv.HOME : undefined;
+ if (managedHome) {
+ const managedLayout: Record<string, string> = {
+ HOME: "",
+ XDG_CONFIG_HOME: "config",
+ XDG_DATA_HOME: "data",
+ CODEX_HOME: "provider",
+ GROK_HOME: "provider",
+ CLAUDE_CONFIG_DIR: "provider",
+ };
+ for (const [key, sub] of Object.entries(managedLayout)) {
+ if (resolvedAdapterEnv[key] === (sub ? path.join(managedHome, sub) : managedHome)) {
+ resolvedAdapterEnv[key] = sub ? `<managed-ai-home>/${sub}` : "<managed-ai-home>";
+ }
+ }
+ }
if (authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler
@@ -2088,6 +2132,15 @@ async function buildRuntime(input: {
runAgentHome: agentHome,
companyId: agent.companyId,
});
+ // Same condition as the fingerprint placeholder above: a resumable session
+ // must find its transcript in the next run's managed home.
+ if (resolvedAdapterEnv.CLAUDE_CONFIG_DIR === "<managed-ai-home>/provider" && env.CLAUDE_CONFIG_DIR) {
+ const transcriptsDir = await linkManagedClaudeTranscripts({
+ claudeConfigDir: env.CLAUDE_CONFIG_DIR,
+ stateDir,
+ });
+ skillCommandNotes.push(`Linked Claude transcripts to ${transcriptsDir}.`);
+ }
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
@@ -0,0 +1,384 @@
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
index 87e18ae..2224e60 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
@@ -21,6 +21,7 @@ import { deleteJob, findPodForJob, getJobStatus } from "./job-orchestrator.js";
import {
deleteSandboxCr,
findPodForSandbox,
+ getSandboxCrStatus,
waitForSandboxReady,
} from "./sandbox-cr-orchestrator.js";
@@ -191,3 +192,60 @@ export async function destroyLeaseResources(
);
}
}
+
+export interface WorkloadGoneInput {
+ namespace: string;
+ /** Workload resource name (Sandbox CR name or Job name) == providerLeaseId. */
+ name: string;
+ backend: "sandbox-cr" | "job";
+ podName?: string | null;
+ /** Stays below the server's 30 s default plugin RPC timeout. */
+ timeoutMs?: number;
+ pollMs?: number;
+}
+
+/**
+ * Poll until the workload (and the named pod, if any) returns 404. Deletes use
+ * Foreground propagation, so the workload disappears only after its pod is
+ * gone. True is the evidence behind a `destroyed` termination receipt; false
+ * (still present at the deadline, or a read failed) means the caller must not
+ * claim one. Never throws: the delete itself already succeeded.
+ */
+export async function waitForWorkloadGone(
+ clients: KubeClients,
+ input: WorkloadGoneInput,
+): Promise<boolean> {
+ const deadline = Date.now() + (input.timeoutMs ?? 20_000);
+ const pollMs = input.pollMs ?? 500;
+ const gone = async (read: () => Promise<unknown>): Promise<boolean> => {
+ try {
+ await read();
+ return false;
+ } catch (err) {
+ if (isKubeNotFoundError(err)) return true;
+ throw err;
+ }
+ };
+ try {
+ return await pollUntilGone();
+ } catch {
+ return false;
+ }
+
+ async function pollUntilGone(): Promise<boolean> {
+ for (;;) {
+ const workloadGone = await gone(() =>
+ input.backend === "sandbox-cr"
+ ? getSandboxCrStatus(clients, input.namespace, input.name)
+ : getJobStatus(clients, input.namespace, input.name),
+ );
+ const podName = input.podName;
+ const podGone = !podName || await gone(() =>
+ clients.core.readNamespacedPod({ namespace: input.namespace, name: podName }),
+ );
+ if (workloadGone && podGone) return true;
+ if (Date.now() >= deadline) return false;
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
+ }
+ }
+}
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
index 9801638..bf5ce0a 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
@@ -15,6 +15,7 @@ import type {
PluginEnvironmentSyncInParams,
PluginEnvironmentSyncOutParams,
PluginEnvironmentSyncResult,
+ PluginEnvironmentTerminationReceipt,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
@@ -38,7 +39,11 @@ import {
} from "./sandbox-cr-orchestrator.js";
import { execInPod, execInPodStreaming, wrapCommandWithEnv } from "./pod-exec.js";
import { performSyncIn, performSyncOut, type PodStreamExec } from "./file-sync.js";
-import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
+import {
+ checkLeaseResumable,
+ destroyLeaseResources,
+ waitForWorkloadGone,
+} from "./lease-lifecycle.js";
import {
appendNetworkEgressDenyHint,
createScopedNetworkEgressPolicyOrReleaseWorkload,
@@ -578,7 +583,7 @@ const plugin = definePlugin({
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -613,11 +618,22 @@ const plugin = definePlugin({
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
}
+
+ // The server grants continuation after a stopped run only with a
+ // termination receipt; without one a cancelled run's queued comment
+ // waits on `remote_cleanup` forever (REN-841). Claim `destroyed` only
+ // once the workload is confirmed gone.
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -658,6 +674,13 @@ const plugin = definePlugin({
podName,
secretName,
});
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ podName,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentExecute(
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
index af0875f..d775799 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
@@ -3,6 +3,7 @@ import {
checkLeaseResumable,
destroyLeaseResources,
isKubeNotFoundError,
+ waitForWorkloadGone,
} from "../../src/lease-lifecycle.js";
const SANDBOX_GROUP = "agents.x-k8s.io";
@@ -346,3 +347,64 @@ describe("destroyLeaseResources", () => {
).rejects.toThrow("forbidden");
});
});
+
+describe("waitForWorkloadGone", () => {
+ const input = { namespace: "ns", name: "pc-abc", backend: "sandbox-cr" as const, pollMs: 1 };
+
+ it("is true once the Sandbox CR and the pod return 404", async () => {
+ const getCr = vi.fn()
+ .mockResolvedValueOnce(readySandboxCr("pc-abc-pod"))
+ .mockRejectedValue(notFound());
+ const readPod = vi.fn().mockRejectedValue(notFound());
+ const clients = { custom: { getNamespacedCustomObject: getCr }, core: { readNamespacedPod: readPod } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod" }),
+ ).resolves.toBe(true);
+ expect(getCr).toHaveBeenCalledTimes(2);
+ expect(getCr).toHaveBeenCalledWith(expect.objectContaining({
+ group: SANDBOX_GROUP, version: SANDBOX_VERSION, plural: SANDBOX_PLURAL, namespace: "ns", name: "pc-abc",
+ }));
+ });
+
+ it("is false while the workload still exists at the deadline", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr()) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("is false while the pod still exists even if the workload is gone", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod", timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("checks the Job for job-backend leases", async () => {
+ const readJob = vi.fn().mockRejectedValue(notFound());
+ const clients = { batch: { readNamespacedJobStatus: readJob } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, backend: "job" }),
+ ).resolves.toBe(true);
+ expect(readJob).toHaveBeenCalledWith({ namespace: "ns", name: "pc-abc" });
+ });
+
+ it("is false (never throws) when a read fails with another error", async () => {
+ const clients = {
+ custom: {
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(Object.assign(new Error("x"), { code: 403 })),
+ },
+ };
+
+ await expect(waitForWorkloadGone(clients as never, input)).resolves.toBe(false);
+ });
+});
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 4c23410..f4fd073 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
@@ -184,12 +184,19 @@ describe("onEnvironmentDestroyLease", () => {
const deletePod = vi.fn().mockResolvedValue({});
const deleteSecret = vi.fn().mockResolvedValue({});
h.clients = {
- custom: { deleteNamespacedCustomObject: deleteCr },
- core: { deleteNamespacedPod: deletePod, deleteNamespacedSecret: deleteSecret },
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ core: {
+ deleteNamespacedPod: deletePod,
+ deleteNamespacedSecret: deleteSecret,
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
batch: { deleteNamespacedJob: vi.fn() },
};
- await plugin.definition.onEnvironmentDestroyLease!({
+ const receipt = await plugin.definition.onEnvironmentDestroyLease!({
driverKey: "kubernetes",
companyId: "acme",
environmentId: "env-1",
@@ -209,14 +216,47 @@ describe("onEnvironmentDestroyLease", () => {
namespace: "paperclip-acme",
name: "pc-abc-env",
});
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
});
it("is idempotent: resolves cleanly when every resource is already gone (404)", async () => {
h.clients = {
- custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
core: {
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: { deleteNamespacedJob: vi.fn() },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentDestroyLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check cannot read the cluster", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("forbidden"), { code: 403 }),
+ ),
+ },
+ core: {
+ deleteNamespacedPod: vi.fn().mockResolvedValue({}),
+ deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
},
batch: { deleteNamespacedJob: vi.fn() },
};
@@ -261,8 +301,12 @@ describe("onEnvironmentDestroyLease", () => {
core: {
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: {
+ deleteNamespacedJob: deleteJob,
+ readNamespacedJobStatus: vi.fn().mockRejectedValue(notFound()),
},
- batch: { deleteNamespacedJob: deleteJob },
};
await plugin.definition.onEnvironmentDestroyLease!({
@@ -280,3 +324,71 @@ describe("onEnvironmentDestroyLease", () => {
expect(deleteCr).not.toHaveBeenCalled();
});
});
+
+describe("onEnvironmentReleaseLease", () => {
+ it("returns a destroyed receipt once the Sandbox CR is gone (REN-841)", async () => {
+ const deleteCr = vi.fn().mockResolvedValue({});
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ const receipt = await plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ });
+
+ expect(deleteCr).toHaveBeenCalledWith(
+ expect.objectContaining({ namespace: "paperclip-acme", name: "pc-abc" }),
+ );
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns a destroyed receipt when the Sandbox CR was already deleted", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check fails", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("server error"), { code: 500 }),
+ ),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toBeUndefined();
+ });
+});
+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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
ARG TOFU_VERSION=1.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
# the cluster instead of on a workstation. The workstation has 31G and the test
@@ -16,7 +16,7 @@ credentials are out of scope (see "Deferred").
|---|---|
| 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). |
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`), then its value replaced with a long-lived `claude setup-token` token. Paperclip stores the value as company secret "My Claude subscription" and injects it per run as `CLAUDE_CODE_OAUTH_TOKEN`; it never refreshes Anthropic credentials, so the login's ~8h access token expires (runs then fail with "ACP agent reported a terminal access failure", 0 tokens). Renew by rotating that secret with a new setup-token (~yearly). 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. |
@@ -189,7 +189,8 @@ available since v0.35.0). First use of digest reflection in this repo.
**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.
Then Settings → Secrets → "My Claude subscription": replace the value with the
output of `claude setup-token`. Repeat both after loss of the database.
## Verification
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
+15
View File
@@ -165,6 +165,21 @@ permanent recovery.
---
## Velero restore-test: 80% OSD capacity gate
The monthly Velero restore-test restores into the production Ceph pools. Before
each schedule's restore it projects the restore's raw size (DataUpload
`totalBytes` x the pool's replication/EC factor, spread over all OSDs) onto
current OSD usage from Prometheus. If the fullest OSD would go above **80%**
(`OSD_MAX_PCT` in `restore-test-cronjob.yaml`), that schedule is not restored:
the Job log shows it as **SKIP** with the projected %, and the summary lists it
under "Skipped (backup NOT restore-tested)". A SKIP does not fail the Job, so
read the summary each month. If the projection cannot be made (Prometheus
down, unsized DataUpload), the schedule is not restored and counts as FAIL.
Background: REN-441, REN-442.
---
## CNPG restore-test (verify backups are restorable — non-destructive)
The monthly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
@@ -48,7 +48,7 @@ spec:
# cert when doing server-side OIDC token exchange. Bundle both
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
- name: fetch-le-staging-roots
image: curlimages/curl:8.10.1
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
@@ -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
@@ -2121,6 +2121,74 @@ data:
- url: https://dash.renner.uno/login/generic_oauth
matching_mode: strict
# Matrix (REN-567): upstream login for MAS (account.matrix.renner.uno).
# Git owns this provider, its application and the member list.
# Public client + PKCE, so there is no client secret to share.
# RS256 with the built-in self-signed keypair: a public client has no
# secret for HS256, and MAS verifies the ID token signature.
- id: matrix-users-group
model: authentik_core.group
identifiers:
name: Matrix Users
state: present
attrs:
is_superuser: false
# Every member can sign in to Matrix and gets an account named
# after their Authentik username. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: matrix-mas-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: Matrix (MAS)
state: present
attrs:
authentication_flow: !KeyOf passkey-passwordless-flow
authorization_flow: !KeyOf passkey-stepup-flow
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: public
client_id: matrix-mas
# Authentik 2026.5 checks the grant type against this list, and a
# provider created by a blueprint starts with it empty, so every
# authorize fails with invalid_request ("Invalid grant_type for
# provider"). Providers made in the UI get the defaults.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
# The path ends in the provider ULID from apps/matrix/mas.yaml.
- url: https://account.matrix.renner.uno/upstream/callback/01M4480KWZGN9HBNB5SSNGT2M1
matching_mode: strict
- model: authentik_core.application
identifiers:
slug: matrix
state: present
attrs:
name: Matrix
slug: matrix
provider: !KeyOf matrix-mas-provider
meta_launch_url: https://element.renner.uno
meta_description: "Matrix chat (Element)"
# Only the Matrix Users group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, matrix]]
group: !KeyOf matrix-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# Superseded by the single passkey-webauthn-validation stage above.
# Their flowstagebindings are FK-CASCADEd away with them; the
# identification stage's webauthn_stage FK is SET_NULL and is
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.310.1
image: evcc/evcc:0.316.2@sha256:070c8a8912c5107e8424cef60bf8c3d3cac0eb32aa28d1b021d226545f0aac7b
# evcc auto-discovers /etc/evcc.yaml on its default search path;
# the file is delivered (with the MQTT password) via the
# sops-encrypted evcc-config Secret mounted below.
+52 -8
View File
@@ -30,6 +30,9 @@ metadata:
# Web Service 9 = Music Assistant
# Web Service 10 = TRMNL
# Web Service 11 = Paperless (gate-level only, see its entry)
# Web Service 12 = Element Web
# Web Service 13 = Matrix (Synapse client API)
# Web Service 14 = Matrix Authentication Service
#
# 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
@@ -44,14 +47,15 @@ metadata:
# production restart for a comment. This header is outside `data:`, so it is
# inert; the served config is byte-identical.
#
# * ../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit
# ALREADY admits the whole monitoring namespace on every port, and
# monitoring is where
# blackbox-exporter runs. Cilium unions the allow rules of every CNP
# selecting an endpoint, so no extra policy is required.
# * The in-cluster paperless probe therefore exists, added with no change
# to the paperless namespace:
# monitoring-rules/probes-blackbox.yaml, target
# * 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
@@ -227,6 +231,46 @@ data:
conditions:
- "[STATUS] == any(200, 301, 302)"
# Matrix (REN-663): Element Web, the Synapse client API and MAS.
- name: Web Service 12
group: services
url: https://element.renner.uno/config.json
interval: 60s
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == 200"
- name: Web Service 13
group: services
url: https://matrix.renner.uno/_matrix/client/versions
interval: 60s
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == 200"
- name: Web Service 14
group: services
url: https://account.matrix.renner.uno/.well-known/openid-configuration
interval: 60s
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == 200"
# Removed 2026-05-21: Jellyfin (media.renner.uno), Wiki (wiki.renner.uno),
# InfluxDB (influx.renner.uno) — hostnames NXDOMAIN, no Ingress; services
# no longer deployed. Re-add when/if they come back.
+2
View File
@@ -6,6 +6,8 @@ kind: Kustomization
# and verified. See MIGRATION-gitea-woodpecker.md.
resources:
- statefulset.yaml
# The live size of the data volume; see the file (REN-583).
- pvc.yaml
- service.yaml
- ingressroute-ssh.yaml
- ingress.yaml
+39
View File
@@ -0,0 +1,39 @@
# The live data volume of gitea-0, owned here so its size is set in git
# (REN-583). The StatefulSet controller created this claim from the
# volumeClaimTemplates in statefulset.yaml, but that template is immutable on
# an existing StatefulSet, so changing the size there cannot be applied. A
# PVC's resources.requests.storage CAN grow in place (ceph-block has
# allowVolumeExpansion: true; ceph-csi grows the RBD image and the filesystem
# online, no pod restart). Flux applies this over the existing claim with
# server-side apply and the resize follows.
#
# - Name, namespace, accessModes, storageClassName and volumeMode must match
# the live claim exactly: they are immutable, a mismatch fails the whole
# gitea Kustomization (no change to the live claim).
# - volumeName must stay: kustomize-controller has owned spec.volumeName
# since the namespace move pre-created this claim (eb23d3ac, #878).
# Without it, server-side apply tries to clear the field and the API server
# rejects the whole claim as an immutable-spec change.
# - Grow only. A smaller value is rejected by the API server.
# - prune: disabled, so removing this file from git never deletes the claim
# and its data. To hand ownership back, delete the file; the claim stays.
#
# Size history: 20Gi -> 40Gi 2026-07-22 (#336, live resize by hand);
# 40Gi -> 60Gi 2026-10-03 (REN-583, after the volume filled again on REN-580).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-gitea-0
namespace: gitea
labels:
app.kubernetes.io/name: gitea
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: ceph-block
volumeMode: Filesystem
volumeName: pvc-e7c56db2-481d-40cb-b94a-b264b7b4b5be
resources:
requests:
storage: 60Gi
+5 -9
View File
@@ -147,14 +147,10 @@ spec:
accessModes: ["ReadWriteOnce"]
storageClassName: ceph-block
resources:
# Expanded 20Gi -> 40Gi on 2026-07-22 (ceph-block online resize) to
# clear a 100%-full outage (#336). The live PVC was resized directly.
# NOTE: volumeClaimTemplates is immutable on an existing StatefulSet,
# so bumping this value cannot be `kubectl apply`-ed in place — Flux
# will reject the whole object. Landing this size change requires a
# one-time `kubectl delete statefulset gitea -n gitea --cascade=orphan`
# (pod + PVC keep running) so Flux re-creates the STS from this
# template and re-adopts them. After the cleanup_packages GC reclaims
# the ~10G of orphaned blobs, real usage is ~9G, so 40Gi is headroom.
# This template only applies when the claim does not exist yet. The
# live size of data-gitea-0 is set in pvc.yaml (REN-583), not here:
# volumeClaimTemplates is immutable on an existing StatefulSet, so a
# change to this value cannot be applied and Flux would reject the
# whole object. Leave it at 40Gi.
requests:
storage: 40Gi
@@ -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
@@ -60,7 +60,7 @@ spec:
# repo's configuration.yaml reads this key, so HA does not start
# without it. Removing this init container takes the recorder down.
- name: render-db-url
image: busybox:1.36
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
command:
- sh
- -c
@@ -159,7 +159,7 @@ spec:
# substitution to POD_IP so nginx-runtime variables like $host
# / $http_x_forwarded_for in the template stay intact.
- name: proxy
image: nginxinc/nginx-unprivileged:1.27-alpine
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:26b0bf6fbf07297983cb341998d79c831508787de26627dd2a112321b9c3a4af
env:
- name: POD_IP
valueFrom:
@@ -193,7 +193,7 @@ spec:
# any LAN client can edit, matching the user's trust-the-network
# model. Runs as root so its writes match HA's root-owned files.
- name: code-server
image: codercom/code-server:4.118.0
image: codercom/code-server:4.140.0@sha256:1c9ad972a78450f430155144110da4c02d3627c72a598e9197e2781fb9f92e28
args:
- "--auth=none"
- "--bind-addr=0.0.0.0:8080"
@@ -36,7 +36,7 @@ spec:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.6.2
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -1,7 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- backup-password-externalsecret.yaml
- pvcs.yaml
- deployment.yaml
- service.yaml
@@ -69,6 +68,11 @@ resources:
- ha-pg-objectstore.yaml
- ha-pg-cluster.yaml
- ha-pg-scheduled-backup.yaml
# MQTT credential management (REN-802): the account controller and, for the
# test phase, its own Dynamic Security broker. Neither touches the
# production broker. Register: mqtt-accounts.yaml (configMapGenerator).
- mosquitto-dynsec-test.yaml
- mqtt-account-controller.yaml
configMapGenerator:
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
# the cm name stays stable; editing proxy.conf does not auto-roll the
@@ -104,5 +108,20 @@ configMapGenerator:
namespace: home-assistant
files:
- guard.sh=zigbee2mqtt-discovery-guard.sh
# Z2M external converters, copied into /app/data/external_converters by the
# seed-data init container on every start. Hash suffix disabled (name stays
# stable), so editing a converter does not auto-roll the pod — restart
# manually with `kubectl rollout restart deploy/zigbee2mqtt -n home-assistant`.
- name: zigbee2mqtt-external-converters
namespace: home-assistant
files:
- wetterstation-ws90.js=zigbee2mqtt-ws90-converter.js
# MQTT account register, read by mqtt-account-controller on every loop.
# Hash suffix disabled (name stays stable); the volume is not a subPath
# mount, so an edit reaches the running controller without a restart.
- name: mqtt-accounts
namespace: home-assistant
files:
- mqtt-accounts.yaml
generatorOptions:
disableNameSuffixHash: true
@@ -42,6 +42,9 @@ spec:
labels:
app.kubernetes.io/name: mdns-relay
annotations:
# Bump to restart the pod and re-attach net1 (DHCP via multus).
# 2026-10-06: first attach on containernetworking/plugins v1.9.1.
homelab.renner.uno/restarted-for: "2026-10-06 cni-plugins v1.9.1 attach check"
k8s.v1.cni.cncf.io/networks: mdns-relay-iot
spec:
enableServiceLinks: false
@@ -82,7 +85,7 @@ spec:
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
image: alpine/socat:1.8.1.3@sha256:82ad20f6f6e29b91ff33b6662d24063522b1378f08fd8569cd3ab412cac13f50
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -124,7 +127,7 @@ spec:
# depends on the kernel's bindv6only default rather than on anything
# stated here, so the roles are made explicit instead of inherited.
- name: socat-ipv6
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
image: alpine/socat:1.8.1.3@sha256:82ad20f6f6e29b91ff33b6662d24063522b1378f08fd8569cd3ab412cac13f50
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -44,6 +44,10 @@ kind: Job
metadata:
name: mosquitto-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -55,7 +59,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -112,6 +116,10 @@ kind: Job
metadata:
name: mosquitto-admin-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -123,7 +131,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -179,6 +187,10 @@ kind: Job
metadata:
name: mosquitto-shellies-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -190,7 +202,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -245,6 +257,10 @@ kind: Job
metadata:
name: mosquitto-sml2mqtt-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -256,7 +272,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -313,6 +329,10 @@ kind: Job
metadata:
name: mosquitto-open3e-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -324,7 +344,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -383,6 +403,10 @@ kind: Job
metadata:
name: mosquitto-zigbee2mqtt-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
@@ -394,7 +418,7 @@ spec:
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
@@ -0,0 +1,233 @@
# Test broker for mqtt-account-controller (REN-802 step 1, REN-888).
#
# A second, throwaway mosquitto with the Dynamic Security plugin and a mutual-
# TLS listener, so the controller can be proven (cert login, reconcile, seed
# after restart, revocation) without touching the production broker. Nothing
# in production connects here.
#
# Deliberately NOT labelled app.kubernetes.io/name: mosquitto: that label is
# the selector of the production Services and of mosquitto-world-ingress, so
# reusing it would put this pod behind mqtt.renner.uno and the IoT VLAN.
# ClusterIP only; reachable from inside the home-assistant namespace.
#
# Removal: delete this file and its kustomization.yaml entry once production
# runs Dynamic Security (REN-802 step 2).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mosquitto-dynsec-test-tls
namespace: home-assistant
spec:
secretName: mosquitto-dynsec-test-tls
dnsNames:
- mosquitto-dynsec-test
- mosquitto-dynsec-test.home-assistant.svc
- mosquitto-dynsec-test.home-assistant.svc.cluster.local
usages:
- digital signature
- server auth
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: mqtt-ca
---
apiVersion: v1
kind: ConfigMap
metadata:
name: mosquitto-dynsec-test-config
namespace: home-assistant
data:
mosquitto.conf: |
persistence false
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
# Dynamic Security owns authentication and ACLs. The plugin writes its
# state back to this file; it is an emptyDir, refilled by the
# initContainer on every start.
plugin /usr/lib/mosquitto_dynamic_security.so
plugin_opt_config_file /mosquitto/dynsec/dynamic-security.json
# Mutual TLS only. The client certificate's CN is the username; the
# Dynamic Security client of that name supplies the roles.
listener 8883
cafile /mosquitto/tls/ca.crt
certfile /mosquitto/tls/tls.crt
keyfile /mosquitto/tls/tls.key
require_certificate true
use_identity_as_username true
allow_anonymous false
# Start file used until the controller has written the Secret
# mqtt-dynsec-seed. No secrets: only the controller's certificate client
# and a role that reaches the Dynamic Security API and nothing else. Same
# shape as the controller's seed renderer (internal/dynsec/config.go).
dynamic-security.json: |
{
"defaultACLAccess": {
"publishClientSend": false,
"publishClientReceive": true,
"subscribe": false,
"unsubscribe": true
},
"clients": [
{
"username": "mqtt-account-controller",
"roles": [{"rolename": "mqtt-account-controller-admin"}]
}
],
"groups": [],
"roles": [
{
"rolename": "mqtt-account-controller-admin",
"acls": [
{"acltype": "publishClientReceive", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
{"acltype": "publishClientSend", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true},
{"acltype": "subscribePattern", "topic": "$CONTROL/dynamic-security/v1/#", "priority": 0, "allow": true}
]
}
]
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mosquitto-dynsec-test
namespace: home-assistant
labels:
app.kubernetes.io/name: mosquitto-dynsec-test
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: mosquitto-dynsec-test
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-dynsec-test
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 10
# Runs as the mosquitto UID from the start instead of starting as root
# and dropping, so the dynsec file the initContainer writes is the
# broker's own.
securityContext:
runAsNonRoot: true
runAsUser: 1883
runAsGroup: 1883
fsGroup: 1883
seccompProfile:
type: RuntimeDefault
initContainers:
# Every start: the controller's seed if it exists, else the static
# base file. The seed carries every account and password hash, so the
# broker is in the desired state before the first client connects.
- name: dynsec-seed
image: eclipse-mosquitto:2.0.22 # {"$imagepolicy": "flux-system:mosquitto"}
command:
- sh
- -c
- |
set -eu
if [ -s /seed/dynamic-security.json ]; then
cp /seed/dynamic-security.json /mosquitto/dynsec/dynamic-security.json
echo "dynsec: loaded seed from Secret mqtt-dynsec-seed"
else
cp /mosquitto/config/dynamic-security.json /mosquitto/dynsec/dynamic-security.json
echo "dynsec: no seed yet, loaded base file"
fi
chmod 0600 /mosquitto/dynsec/dynamic-security.json
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /mosquitto/config
readOnly: true
- name: seed
mountPath: /seed
readOnly: true
- name: dynsec
mountPath: /mosquitto/dynsec
containers:
- name: mosquitto
# Same tag and image policy as the production broker, so the test
# runs the version that production will run.
image: eclipse-mosquitto:2.0.22 # {"$imagepolicy": "flux-system:mosquitto"}
command: ["mosquitto", "-c", "/mosquitto/config/mosquitto.conf"]
ports:
- name: mqtts
containerPort: 8883
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /mosquitto/config
readOnly: true
- name: tls
mountPath: /mosquitto/tls
readOnly: true
- name: dynsec
mountPath: /mosquitto/dynsec
readinessProbe:
tcpSocket:
port: mqtts
periodSeconds: 10
livenessProbe:
tcpSocket:
port: mqtts
periodSeconds: 30
failureThreshold: 6
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
volumes:
- name: config
configMap:
name: mosquitto-dynsec-test-config
- name: tls
secret:
secretName: mosquitto-dynsec-test-tls
# Written by mqtt-account-controller. Optional: absent on the very
# first start, when the base file is used instead.
- name: seed
secret:
secretName: mqtt-dynsec-seed
optional: true
- name: dynsec
emptyDir:
sizeLimit: 4Mi
---
apiVersion: v1
kind: Service
metadata:
name: mosquitto-dynsec-test
namespace: home-assistant
labels:
app.kubernetes.io/name: mosquitto-dynsec-test
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: mosquitto-dynsec-test
ports:
- name: mqtts
port: 8883
targetPort: mqtts
protocol: TCP
@@ -0,0 +1,222 @@
# mqtt-account-controller (REN-802, code REN-837, deploy REN-888).
# Source: konairius/mqtt-account-controller (maintainer Hephaestus). Reconciles
# the broker's Dynamic Security clients/roles/ACLs, the cert-manager
# Certificates of `auth: cert` accounts and device passwords to the register
# mqtt-accounts.yaml, every INTERVAL (2m).
#
# TEST PHASE: BROKER_URL points at mosquitto-dynsec-test, not the production
# broker. Switching it to `mosquitto` is REN-802 step 2 (HA window).
apiVersion: v1
kind: ServiceAccount
metadata:
name: mqtt-account-controller
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
---
# Find the Certificates it manages (label app.kubernetes.io/managed-by=
# mqtt-account-controller) in every namespace. List only: no get, watch or
# write cluster-wide.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mqtt-account-controller-list-certificates
labels:
app.kubernetes.io/name: mqtt-account-controller
rules:
- apiGroups: ["cert-manager.io"]
resources: ["certificates"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mqtt-account-controller-list-certificates
labels:
app.kubernetes.io/name: mqtt-account-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mqtt-account-controller-list-certificates
subjects:
- kind: ServiceAccount
name: mqtt-account-controller
namespace: home-assistant
---
# Per deliver.namespace: manage the account Certificates there (server-side
# apply = patch, which creates through `create`). One Role + RoleBinding per
# namespace that appears as `deliver.namespace` in the register; today only
# home-assistant.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mqtt-account-controller-certificates
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
rules:
- apiGroups: ["cert-manager.io"]
resources: ["certificates"]
verbs: ["get", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mqtt-account-controller-certificates
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mqtt-account-controller-certificates
subjects:
- kind: ServiceAccount
name: mqtt-account-controller
namespace: home-assistant
---
# Secrets in home-assistant, by name only. This namespace holds every broker
# and HA credential, so an unnamed `delete secrets` grant would let the
# controller wipe all of them.
# mqtt-account-hashes, mqtt-dynsec-seed: its own state (server-side apply
# creates them, hence create + patch; resourceNames hold for an apply
# that creates, because the name is in the request path).
# mqtt-<name>-tls: delete on revocation, one entry per cert account
# delivered to this namespace. Add the name with the register entry.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mqtt-account-controller-secrets-home-assistant
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed"]
verbs: ["get", "create", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
- mqtt-test-cert-tls
- mqtt-test-revoke-tls
verbs: ["delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mqtt-account-controller-secrets-home-assistant
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mqtt-account-controller-secrets-home-assistant
subjects:
- kind: ServiceAccount
name: mqtt-account-controller
namespace: home-assistant
---
# The controller's own MQTT identity: CN = Dynamic Security admin client
# (ADMIN_USER). Not labelled managed-by, so the controller never treats it
# as an account certificate and never revokes it. Re-read on every TLS
# handshake, so a renewal needs no restart.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mqtt-account-controller
namespace: home-assistant
spec:
secretName: mqtt-account-controller-tls
commonName: mqtt-account-controller
usages:
- digital signature
- client auth
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: mqtt-ca
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mqtt-account-controller
namespace: home-assistant
labels:
app.kubernetes.io/name: mqtt-account-controller
spec:
replicas: 1
# One writer at a time: two controllers would race on rotations and the
# seed Secret.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: mqtt-account-controller
template:
metadata:
labels:
app.kubernetes.io/name: mqtt-account-controller
spec:
serviceAccountName: mqtt-account-controller
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
# Woodpecker builds main-<sha> on every merge to main; no sortable
# tag, so bumps are manual (same as ical-proxy).
image: gitea.renner.uno/konairius/mqtt-account-controller:main-47213ef
env:
- name: BROKER_URL
value: ssl://mosquitto-dynsec-test.home-assistant.svc.cluster.local:8883
# Not :8080: several :8080 ingress grants in the home-assistant
# default-deny apply to every pod in the namespace.
- name: LISTEN
value: ":8081"
ports:
- name: http
containerPort: 8081
protocol: TCP
# /healthz is 200 while a reconcile succeeded within 3 x INTERVAL.
# Readiness only: a restart does not fix an unreachable broker.
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: register
mountPath: /etc/mqtt-accounts
readOnly: true
- name: tls
mountPath: /etc/mqtt-tls
readOnly: true
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
volumes:
# Not a subPath mount, so a register change reaches the running pod
# (kubelet sync, ~1 min) and the next loop reads it without a restart.
- name: register
configMap:
name: mqtt-accounts
- name: tls
secret:
secretName: mqtt-account-controller-tls
@@ -0,0 +1,26 @@
# MQTT account register (REN-802). The only definition of who may use the
# broker: mqtt-account-controller reconciles certificates, device passwords
# and Dynamic Security clients/roles/ACLs to this list. Format: README of
# konairius/mqtt-account-controller.
#
# Delivered to the controller as the ConfigMap `mqtt-accounts` (see the
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
# never revokes everyone.
#
# TEST PHASE (REN-888): the controller points at the test broker
# `mosquitto-dynsec-test`, not the production `mosquitto`. Both entries are
# certificate test accounts. `test-revoke` exists to be deleted again, which
# checks revocation (Certificate and Secret gone, client and role gone).
#
# A cert account needs its Secret `mqtt-<name>-tls` added to the
# `mqtt-account-controller-secrets-<namespace>` Role in
# mqtt-account-controller.yaml, or revoking it fails with 403.
- name: test-cert
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite test/cert/#"]
- name: test-revoke
auth: cert
deliver: {namespace: home-assistant}
acl: ["read test/revoke/#"]
@@ -61,7 +61,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/k.renner/shelly-manager:build.1784957186 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1791295339 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -132,6 +132,13 @@ spec:
else
echo "existing Z2M state present; NOT touching coordinator_backup.json"
fi
# External converters: overwritten from git on EVERY start (not
# seed-only). Copied rather than mounted because Z2M writes a temp
# copy of each converter into this directory while loading it
# (dist/extension/externalJS.js), which a read-only mount refuses.
mkdir -p /app/data/external_converters
cp /external-converters/*.js /app/data/external_converters/
echo "copied external converters: $(ls /external-converters)"
env:
- name: MQTT_PASSWORD
valueFrom:
@@ -147,6 +154,9 @@ spec:
- name: coordinator-backup
mountPath: /backup
readOnly: true
- name: external-converters
mountPath: /external-converters
readOnly: true
resources:
requests:
cpu: 10m
@@ -158,7 +168,7 @@ spec:
# Pinned tag. No Flux image-automation marker yet (unlike mosquitto) —
# add an infrastructure/image-automation/zigbee2mqtt.yaml policy as a
# follow-up if desired; pinned-and-manual is fine for the initial cutover.
image: ghcr.io/koenkk/zigbee2mqtt:2.13.0
image: ghcr.io/koenkk/zigbee2mqtt:2.14.2@sha256:addbfb08fcef2e4477de1535636118241e10d13e898fbe4d9faba8a4d7396311
imagePullPolicy: IfNotPresent
env:
- name: TZ
@@ -194,12 +204,15 @@ spec:
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 4
# Node sizes the V8 heap from this limit: 512Mi gave a 259 MB heap,
# and Z2M died on "JavaScript heap out of memory" (exit 134) at
# 2026-09-28 17:56Z and 2026-09-29 07:36Z.
resources:
requests:
cpu: 50m
memory: 128Mi
memory: 256Mi
limits:
memory: 512Mi
memory: 1Gi
# Fires a forced interview whenever a still-un-interviewed device
# transmits — i.e. at the only moment a sleepy battery device can
# actually complete one. Z2M itself only retries interviews on a ZDO
@@ -337,6 +350,9 @@ spec:
- name: config-template
configMap:
name: zigbee2mqtt-config
- name: external-converters
configMap:
name: zigbee2mqtt-external-converters
- name: coordinator-backup
secret:
secretName: zigbee2mqtt-coordinator-backup
@@ -0,0 +1,48 @@
// Wetterstation (WS90) = the built-in zigbee-herdsman-converters definition
// plus one extra configure step that applies the reporting configuration from
// zigbee2mqtt-ws90-reporting.md. Z2M runs configure after every interview and
// on bridge/request/device/configure, so those no longer revert the device to
// the converter defaults (as happened 2026-09-24..28).
//
// The extra step runs after the built-in ones (extend order), so its values
// win. `version` makes Z2M reconfigure once when this file is first loaded:
// it re-runs configure when definition.version differs from the device's
// meta.configured. Bump it whenever REPORTING changes.
const {definitions} = require("zigbee-herdsman-converters/devices/shelly");
const base = definitions.find((d) => d.model === "WS90");
if (!base) throw new Error("WS90 definition not found in zigbee-herdsman-converters/devices/shelly");
const SHELLY = 5264;
// [cluster, attribute, reportable_change, manufacturerCode]
const REPORTING = [
["msTemperatureMeasurement", "measuredValue", 10],
["msRelativeHumidity", "measuredValue", 50],
["msPressureMeasurement", "measuredValue", 1],
["msIlluminanceMeasurement", "measuredValue", 5],
["shellyWS90Wind", "windSpeed", 1, SHELLY],
["shellyWS90Wind", "windDirection", 1, SHELLY],
["shellyWS90Wind", "gustSpeed", 1, SHELLY],
["shellyWS90UV", "uvIndex", 1, SHELLY],
["shellyWS90Rain", "rainStatus", 1, SHELLY],
["shellyWS90Rain", "precipitation", 1, SHELLY],
];
const reporting = {
isModernExtend: true,
configure: [
async (device) => {
const endpoint = device.getEndpoint(1);
for (const [cluster, attribute, change, manufacturerCode] of REPORTING) {
await endpoint.configureReporting(
cluster,
[{attribute, minimumReportInterval: 5, maximumReportInterval: 3600, reportableChange: change}],
manufacturerCode ? {manufacturerCode} : {},
);
}
},
],
};
module.exports = {...base, version: "ws90-reporting-1", extend: [...base.extend, reporting]};
@@ -2,10 +2,15 @@
Records the ZCL attribute-reporting configuration applied to the Wetterstation
(Ecowitt WS90 powered by Shelly, IEEE `0xc02cedfffe958f40`) and how to restore
it, because **this configuration lives on the device and in Z2M's
`database.db`, not in git**. Zigbee2MQTT has no settings-schema field for
reporting configuration, so there is nothing declarative to reconcile — Flux
cannot restore it and a fresh PVC will not have it.
it. The configuration itself lives on the device and in Z2M's `database.db`.
**Since 2026-10-01 it is applied from git:** the external converter
`zigbee2mqtt-ws90-converter.js` extends the built-in WS90 definition with a
configure step that sets the values below, so every interview and every
`bridge/request/device/configure` applies them instead of the converter
defaults. The manual commands under "Re-applying" are only needed if the
converter is not loaded. Keep the table and `REPORTING` in the converter in
sync, and bump the converter's `version` when they change.
## Applied configuration
@@ -71,14 +76,16 @@ the modernExtend value is the outlier.
## When this is lost
Anything that makes Z2M re-run `configure` on the device overwrites all of it
with the converter defaults:
Without the external converter, anything that makes Z2M re-run `configure` on
the device overwrites all of it with the converter defaults:
- re-pairing or re-joining the device
- re-pairing, re-joining or re-interviewing the device
- an explicit `zigbee2mqtt/bridge/request/device/configure`
- a converter update that changes the definition and triggers reconfiguration
It also will not exist on a fresh PVC. Re-apply with the commands below.
With the converter loaded, those same events apply the values above. Check
that it is loaded: Z2M logs `Successfully configured 'Wetterstation'
(definition vws90-reporting-1)` after a configure.
## Re-applying
+6 -3
View File
@@ -46,12 +46,15 @@ spec:
# 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. The summary_match/summary_replace parameters
# this deployment needs are unchanged.
# /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>, the first 7 characters of the main commit.
image: gitea.renner.uno/konairius/ical-proxy:main-73b5d97
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
+3 -18
View File
@@ -8,26 +8,11 @@
# back into `kubernetes/apps/immich/` at retirement is the cleanup.
#
# Sizing:
# - library: 300Gi — LXC 225's /mnt/media/immich is currently ~250 GiB.
# StorageClass ceph-filesystem has allowVolumeExpansion=true; grow with
# `kubectl patch pvc immich-library -p '{"spec":{"resources":{"requests":{"storage":"<new>"}}}}'`.
# - library-bulk: 300Gi — the media library is ~250 GiB.
# StorageClass ceph-filesystem-bulk has allowVolumeExpansion=true; grow with
# `kubectl patch pvc immich-library-bulk -p '{"spec":{"resources":{"requests":{"storage":"<new>"}}}}'`.
# - ml-cache: 20Gi — face-recognition + CLIP model cache. Survives pod
# restarts so ML doesn't re-download on every rollout.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-library
namespace: immich
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 300Gi
---
# Media root on the EC 2+1 bulk pool; thumbs mount separately below.
apiVersion: v1
kind: PersistentVolumeClaim
+2 -2
View File
@@ -66,7 +66,7 @@ spec:
# The library PVC is populated by the seed phase before this
# Deployment reconciles, so no rsync init is needed here.
- name: fetch-le-staging-roots
image: curlimages/curl:8.10.1
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
@@ -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
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.7.4
image: influxdb:2.9.1@sha256:db0bdab1e5ad5ee899c127b8c13d9c986a3ced78cd9200dd80a1064bf1533b6e
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -80,7 +80,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.7.4
image: influxdb:2.9.1@sha256:db0bdab1e5ad5ee899c127b8c13d9c986a3ced78cd9200dd80a1064bf1533b6e
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+3 -3
View File
@@ -88,7 +88,7 @@ spec:
# state). Two-stage so we don't try to create an auth before
# the chart's `influx setup` has finished.
- name: wait-for-influxd
image: influxdb:2.7.4
image: influxdb:2.9.1@sha256:db0bdab1e5ad5ee899c127b8c13d9c986a3ced78cd9200dd80a1064bf1533b6e
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -111,7 +111,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.7.4
image: influxdb:2.9.1@sha256:db0bdab1e5ad5ee899c127b8c13d9c986a3ced78cd9200dd80a1064bf1533b6e
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -133,7 +133,7 @@ spec:
set -euo pipefail
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
# Need jq + kubectl. influxdb:2.7.4 has neither baked in.
# Need jq + kubectl. The influxdb image has neither baked in.
apt-get update -qq
apt-get install -y --no-install-recommends curl jq ca-certificates >/dev/null
KV=$(curl -fsSL https://dl.k8s.io/release/stable.txt)
+9 -2
View File
@@ -53,8 +53,9 @@ spec:
values:
image:
repository: influxdb
# Match VM 108 for format compatibility with `influx restore`.
tag: "2.7.4"
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
tag: "2.9.1@sha256:db0bdab1e5ad5ee899c127b8c13d9c986a3ced78cd9200dd80a1064bf1533b6e"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
@@ -93,6 +94,12 @@ spec:
env:
- name: INFLUXD_STORAGE_WRITE_TIMEOUT
value: "60s"
# 2.9 hashes API tokens on startup by default. Hashed tokens cannot be
# read back, so the token Jobs' `influx auth list` reuse would break,
# and a downgrade below 2.8 would erase every token (HA, Grafana).
# Keep tokens unhashed; enable hashing later as its own change.
- name: INFLUXD_USE_HASHED_TOKENS
value: "false"
# The chart defaults to liveness/readiness with initialDelaySeconds=0,
# so after a crash restart the kubelet kills the pod before shards
# finish opening. Give it 10 minutes to come up cleanly.
@@ -5,3 +5,4 @@ resources:
- helmrelease.yaml
- ha-token-job.yaml
- grafana-token-job.yaml
- volumesnapshot-pre-2.9.yaml
@@ -0,0 +1,18 @@
---
# Point-in-time copy of the InfluxDB data PVC, taken before the 2.7.4 -> 2.9.1
# upgrade (homelab #1130, REN-723). Merge this first and check readyToUse=true
# before #1130: 2.9 migrates the metadata store, so the way back is
# `influxd downgrade 2.4` or a restore from this snapshot.
#
# Crash-consistent (taken from the live RBD volume), like the velero CSI
# snapshots. Delete this file once 2.9 has run cleanly; the snapshot goes
# with it (prune).
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: influxdb-influxdb2-pre-2-9
namespace: influxdb
spec:
volumeSnapshotClassName: ceph-block-snapclass
source:
persistentVolumeClaimName: influxdb-influxdb2
+2 -2
View File
@@ -48,7 +48,7 @@ spec:
# this sentinel: the entrypoint copies AccountsSettings.json
# into ephemeral /config-internal rather than symlinking it.
- name: seed-config
image: busybox:1.37.0
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
command: ["/bin/sh", "-eu", "-c"]
args:
- |
@@ -94,7 +94,7 @@ spec:
- name: libation
# Pinned by digest: Spegel caches mutable tag resolutions, so
# :latest stayed on 13.7.11 despite imagePullPolicy Always.
image: docker.io/rmcrackan/libation:14.2.1@sha256:6f9d8ba19dc3803dd6ba295ab5a7abfb6ff3bc06286eebf4ecd8ccbe43245888
image: docker.io/rmcrackan/libation:14.2.2@sha256:fe188ac3c4ce63cc305971c76e648748341427e16bcf884011da8a384fd3908c
# The entrypoint stages config into ephemeral /config-internal
# by copy (only the DB is symlinked to the PVC), so Audible
# tokens refreshed during a run are lost at container exit.
+222
View File
@@ -0,0 +1,222 @@
# matrix-bridge (REN-569, REN-587): Matrix appservice between Element and the
# Paperclip agents. One room per agent with the owner; owner messages become
# comments on the agent's chat card, the agent's comments come back to the
# room. Source: gitea.renner.uno/konairius/matrix-bridge.
#
# No volume: the bridge keeps its state in the bot's account data on Synapse.
# No ingress: only Synapse talks to it (appservice transactions on :8080).
#
# Secrets:
# matrix-bridge-tokens as_token (bridge -> Synapse) and hs_token (Synapse
# -> bridge), generated once in-cluster by ESO, plus
# the appservice registration Synapse loads from
# app_service_config_files (synapse.yaml). Both sides
# read the same Secret, so the tokens cannot drift.
# Deleting the Secret regenerates both; Reloader
# restarts Synapse and the bridge.
# matrix-bridge paperclip_api_key from OpenBao
# homelab/apps/matrix-bridge: the Paperclip board key
# "matrix-bridge (board)" (REN-582).
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: matrix-bridge-tokens
namespace: matrix
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- as_token
- hs_token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: matrix-bridge-tokens
namespace: matrix
spec:
# Generate once; Periodic would rotate the tokens every refresh.
refreshPolicy: CreatedOnce
target:
name: matrix-bridge-tokens
template:
engineVersion: v2
data:
as_token: "{{ .as_token }}"
hs_token: "{{ .hs_token }}"
# Agent users are @agent_<urlKey>:matrix.renner.uno; the bot is
# @paperclip:matrix.renner.uno.
registration.yaml: |
id: matrix-bridge
url: http://matrix-bridge:8080
as_token: "{{ .as_token }}"
hs_token: "{{ .hs_token }}"
sender_localpart: paperclip
rate_limited: false
namespaces:
users:
- exclusive: true
regex: "@agent_.*:matrix\\.renner\\.uno"
aliases: []
rooms: []
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: matrix-bridge-tokens
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: matrix-bridge
namespace: matrix
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: matrix-bridge
data:
- secretKey: paperclip_api_key
remoteRef:
key: apps/matrix-bridge
property: paperclip_api_key
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: matrix-bridge
namespace: matrix
annotations:
secret.reloader.stakater.com/reload: matrix-bridge,matrix-bridge-tokens
labels:
app.kubernetes.io/name: matrix-bridge
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
# One poller only: two pods would bridge every comment twice.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: matrix-bridge
template:
metadata:
labels:
app.kubernetes.io/name: matrix-bridge
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
# `node` in node:24-alpine.
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: matrix-bridge
image: gitea.renner.uno/konairius/matrix-bridge:build.1791195981
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: MATRIX_HOMESERVER_URL
value: http://synapse:8008
- name: MATRIX_SERVER_NAME
value: matrix.renner.uno
- name: MATRIX_OWNER
value: "@k.renner:matrix.renner.uno"
- name: MATRIX_AS_TOKEN
valueFrom:
secretKeyRef:
name: matrix-bridge-tokens
key: as_token
- name: MATRIX_HS_TOKEN
valueFrom:
secretKeyRef:
name: matrix-bridge-tokens
key: hs_token
- name: PAPERCLIP_API_URL
value: http://paperclip.paperclip.svc.cluster.local:3100
- name: PAPERCLIP_API_KEY
valueFrom:
secretKeyRef:
name: matrix-bridge
key: paperclip_api_key
- name: PAPERCLIP_COMPANY_ID
value: 72f7b75a-10b0-4084-ad5e-a0cdf31c7caf
- name: PAPERCLIP_PROJECT_ID
value: 67143c0b-e9a1-44c1-b012-0f8fa25625aa
# Card link in the topic of each per-card room (REN-668).
- name: PAPERCLIP_PUBLIC_URL
value: https://paperclip.renner.uno
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
# /health is 503 when the Paperclip comment poll fails, so it gates
# readiness only; a Paperclip outage must not restart the bridge.
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
livenessProbe:
tcpSocket:
port: http
periodSeconds: 30
failureThreshold: 4
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: matrix-bridge
namespace: matrix
spec:
selector:
app.kubernetes.io/name: matrix-bridge
ports:
- name: http
port: 8080
targetPort: http
---
# The bridge calls the Paperclip API in-cluster. Synapse <-> bridge and DNS
# are covered by matrix-app-egress (part-of: matrix); the Paperclip side of
# this flow is in apps/paperclip/network-policy.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: matrix-bridge-paperclip-egress
namespace: matrix
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: matrix-bridge
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
+45
View File
@@ -0,0 +1,45 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: mx-pg
namespace: matrix
spec:
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
imageName: ghcr.io/cloudnative-pg/postgresql:16
enableSuperuserAccess: true
storage:
storageClass: ceph-block
size: 5Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: mx-pg-store
bootstrap:
initdb:
database: synapse
owner: synapse
# Synapse refuses to start on a database whose collation is not C
# (its text indexes depend on byte order). Fixed at initdb time; it
# cannot be changed on an existing database.
encoding: UTF8
localeCollate: C
localeCType: C
postgresql:
parameters:
shared_buffers: "128MB"
max_connections: "50"
# Same write-reduction set as the other CNPG clusters; rationale in
# apps/trmnl/cluster.yaml.
wal_compression: "zstd"
archive_timeout: "30min"
checkpoint_timeout: "15min"
max_wal_size: "2GB"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
+137
View File
@@ -0,0 +1,137 @@
# Element Web at element.renner.uno, locked to matrix.renner.uno: no custom
# homeserver, no guests, no registration, no integration manager, no
# analytics (no posthog block), no bug-report endpoint.
#
# feature_login_with_qr: "Sign in with QR code" on the welcome page, so a new
# browser can be linked from a signed-in device. Linking a phone from the
# desktop (Settings → Sessions → Link new device) needs no flag; it shows
# when the server offers MSC4108 and the session has cross-signing.
apiVersion: v1
kind: ConfigMap
metadata:
name: element-config
namespace: matrix
data:
config.json: |
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.renner.uno",
"server_name": "matrix.renner.uno"
}
},
"disable_custom_urls": true,
"disable_guests": true,
"disable_3pid_login": true,
"default_country_code": "DE",
"room_directory": { "servers": ["matrix.renner.uno"] },
"integrations_ui_url": "",
"integrations_rest_url": "",
"integrations_widgets_urls": [],
"bug_report_endpoint_url": "",
"show_labs_settings": false,
"features": {
"feature_login_with_qr": true
},
"setting_defaults": {
"UIFeature.registration": false,
"UIFeature.passwordReset": false,
"UIFeature.thirdPartyId": false,
"UIFeature.identityServer": false,
"UIFeature.deactivate": false
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: element-web
namespace: matrix
annotations:
configmap.reloader.stakater.com/reload: element-config
labels:
app.kubernetes.io/name: element-web
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: element-web
template:
metadata:
labels:
app.kubernetes.io/name: element-web
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.30
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
# Unprivileged port: the image runs as nginx, not root.
- name: ELEMENT_WEB_PORT
value: "8080"
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
readinessProbe:
httpGet:
path: /config.json
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /config.json
port: http
periodSeconds: 30
volumeMounts:
# The entrypoint copies /app/config*.json to /tmp and serves it
# from there, so the ConfigMap lands at the source path.
- name: config
mountPath: /app/config.json
subPath: config.json
readOnly: true
# Writable paths for a read-only root: the entrypoint renders the
# nginx template into conf.d; pid and temp files go to /tmp.
- name: conf-d
mountPath: /etc/nginx/conf.d
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: element-config
- name: conf-d
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: element-web
namespace: matrix
spec:
selector:
app.kubernetes.io/name: element-web
ports:
- name: http
port: 80
targetPort: http
@@ -0,0 +1,26 @@
# OpenBao homelab/apps/matrix (seeded by Hermes):
# owner_user localpart of the owner account (k.renner)
# owner_password the owner's password (MAS password login)
# registration_shared_secret random; Synapse's macaroon key fallback
# mas_matrix_secret random; MAS <-> Synapse shared secret
# mas_encryption 32 bytes hex; MAS encrypts tokens at rest
# mas_rsa_key, mas_ec_key PEM private keys; MAS signs its tokens
#
# The owner reads the login from OpenBao; it never appears in a comment, a
# manifest or a log. owner_password is the password the account was created
# with; changing it in OpenBao does not change the account password.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: synapse-secrets
namespace: matrix
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: synapse-secrets
dataFrom:
- extract:
key: apps/matrix
@@ -0,0 +1,15 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: matrix
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
+161
View File
@@ -0,0 +1,161 @@
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
# netbird-service-sync publishes its host through the NetBird proxy
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
#
# Synapse: only the client-facing paths. /_synapse/admin stays off the
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
# served by the listener anyway.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: synapse
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- matrix.renner.uno
secretName: matrix-tls
rules:
- host: matrix.renner.uno
http:
paths:
# Legacy login/logout/refresh go to MAS (its compat layer). Longer
# paths win in Traefik, so these override /_matrix/client below.
- path: /_matrix/client/v3/login
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client/v3/logout
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client/v3/refresh
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client/r0/login
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client/r0/logout
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client/r0/refresh
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
- path: /_matrix/client
pathType: Prefix
backend:
service:
name: synapse
port:
number: 8008
- path: /_matrix/media
pathType: Prefix
backend:
service:
name: synapse
port:
number: 8008
- path: /_synapse/client
pathType: Prefix
backend:
service:
name: synapse
port:
number: 8008
- path: /.well-known/matrix/client
pathType: Exact
backend:
service:
name: synapse
port:
number: 8008
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: element-web
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- element.renner.uno
secretName: element-tls
rules:
- host: element.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: element-web
port:
number: 80
---
# MAS: login pages, OAuth endpoints and account management.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mas
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- account.matrix.renner.uno
secretName: mas-tls
rules:
- host: account.matrix.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mas
port:
number: 8080
+15
View File
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- synapse.yaml
- mas.yaml
- element.yaml
- bridge.yaml
- ingress.yaml
# mx-pg CNPG Postgres + offsite Barman backup. The media store and signing
# key are on synapse-data, backed up by Velero (matrix-daily).
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
+229
View File
@@ -0,0 +1,229 @@
# Matrix Authentication Service (MAS) for matrix.renner.uno (REN-567).
#
# Synapse delegates all logins to MAS (matrix_authentication_service in
# synapse.yaml). Why: Element X only signs in with a password or through
# MAS. MAS gives it a one-tap passkey login through Authentik
# (upstream_oauth2 below) and makes QR sign-in possible.
#
# Accounts: the owner account was moved from Synapse into MAS by syn2mas
# (the migrate-to-mas init container in synapse.yaml), with its password
# hash and sessions. The first Authentik login links to it by localpart
# (on_conflict: add). The Authentik application admits only the Matrix
# Users group, so no one else can create an account through it. Password
# login stays on as a fallback; self-registration and recovery are off.
#
# Secrets: OpenBao homelab/apps/matrix (synapse-secrets). The database is
# `mas` in mx-pg (Barman backup); MAS keeps no other state.
apiVersion: v1
kind: ConfigMap
metadata:
name: mas-config
namespace: matrix
data:
config.yaml: |
http:
public_base: https://account.matrix.renner.uno/
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
- name: assets
binds:
- address: "[::]:8080"
- name: internal
resources:
- name: health
binds:
- address: "[::]:8081"
# Password from the mounted mx-pg-app Secret; nothing in this file.
database:
host: mx-pg-rw
port: 5432
username: synapse
password_file: /pg/password
database: mas
matrix:
kind: synapse
homeserver: matrix.renner.uno
endpoint: http://synapse:8008/
secret_file: /secrets/mas_matrix_secret
secrets:
encryption_file: /secrets/mas_encryption
keys:
- key_file: /secrets/mas_rsa_key
- key_file: /secrets/mas_ec_key
passwords:
enabled: true
# Version 1 must be bcrypt with no pepper: syn2mas imports Synapse's
# bcrypt hashes under it. New passwords are hashed with argon2id.
schemes:
- version: 1
algorithm: bcrypt
- version: 2
algorithm: argon2id
minimum_complexity: 3
account:
password_registration_enabled: false
password_recovery_enabled: false
email_change_allowed: false
login_with_email_allowed: false
account_deactivation_allowed: false
# No mail server; MAS sends nothing.
email:
transport: blackhole
upstream_oauth2:
providers:
- id: 01M4480KWZGN9HBNB5SSNGT2M1
human_name: Authentik (passkey)
issuer: https://auth.renner.uno/application/o/matrix/
client_id: matrix-mas
# Public client + PKCE: no shared client secret to keep in sync.
token_endpoint_auth_method: none
pkce_method: always
scope: "openid profile email"
claims_imports:
# Authentik username = Matrix localpart. `add` links the first
# login to the migrated account of the same name.
localpart:
action: require
template: "{{ user.preferred_username }}"
on_conflict: add
displayname:
action: suggest
template: "{{ user.name }}"
email:
action: ignore
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mas
namespace: matrix
annotations:
configmap.reloader.stakater.com/reload: mas-config
secret.reloader.stakater.com/reload: synapse-secrets
labels:
app.kubernetes.io/name: mas
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mas
template:
metadata:
labels:
app.kubernetes.io/name: mas
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports:
- name: http
containerPort: 8080
- name: internal
containerPort: 8081
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /health
port: internal
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: internal
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 4
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: secrets
mountPath: /secrets
readOnly: true
- name: pg
mountPath: /pg
readOnly: true
volumes:
- name: config
configMap:
name: mas-config
- name: secrets
secret:
secretName: synapse-secrets
items:
- key: mas_matrix_secret
path: mas_matrix_secret
- key: mas_encryption
path: mas_encryption
- key: mas_rsa_key
path: mas_rsa_key
- key: mas_ec_key
path: mas_ec_key
- name: pg
secret:
secretName: mx-pg-app
items:
- key: password
path: password
---
apiVersion: v1
kind: Service
metadata:
name: mas
namespace: matrix
spec:
selector:
app.kubernetes.io/name: mas
ports:
- name: http
port: 8080
targetPort: http
---
# `mas` database in mx-pg, owned by the synapse role (one app, one
# namespace), so MAS reuses mx-pg-app. Barman backs it up with synapse.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: mx-pg-mas
namespace: matrix
spec:
cluster:
name: mx-pg
name: mas
owner: synapse
+30
View File
@@ -0,0 +1,30 @@
---
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: mx-pg-store
namespace: matrix
spec:
configuration:
# Offsite Hetzner Object Storage (fsn1), same bucket as the other CNPG
# clusters. No SSE: Hetzner supports only SSE-C, unusable by barman-cloud
# (plugin #646), so at-rest encryption is unavailable; transit is TLS.
destinationPath: s3://renner-uno-k8s-cnpg/mx-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 is required (REGION=fsn1) — without it boto3's HeadBucket
# (barman-cloud-check-wal-archive during bootstrap.recovery) 400s.
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
@@ -0,0 +1,16 @@
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: mx-pg-daily
namespace: matrix
spec:
# 03:00 local, same slot as the other CNPG clusters (the Velero PVC
# schedule for this namespace runs later, at 09:45).
schedule: "0 0 3 * * *"
immediate: true
backupOwnerReference: self
cluster:
name: mx-pg
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
+324
View File
@@ -0,0 +1,324 @@
# Synapse homeserver for matrix.renner.uno (REN-567).
#
# Private: federation is off at three layers — federation_domain_whitelist
# is empty, no listener serves the federation/keys resources, and the
# namespace egress policy lets Synapse reach nothing outside the cluster
# except the push gateway. Logins and accounts are delegated to MAS
# (mas.yaml); registration is closed.
#
# server_name is permanent: it is part of every user and room ID, and
# cannot be changed without a new, empty server. matrix.renner.uno needs no
# .well-known delegation from the apex, which is the WordPress site.
apiVersion: v1
kind: ConfigMap
metadata:
name: synapse-config
namespace: matrix
data:
homeserver.yaml: |
server_name: matrix.renner.uno
public_baseurl: https://matrix.renner.uno/
# Lets Element X and other clients discover the homeserver from the
# server name. No server well-known: there is no federation to point at.
serve_client_wellknown: true
pid_file: /tmp/homeserver.pid
report_stats: false
listeners:
# Client API only (includes media and /.well-known). Leaving out the
# `federation` and `keys` resources means this server does not answer
# federation at all.
- port: 8008
type: http
tls: false
bind_addresses: ["::"]
x_forwarded: true
resources:
- names: [client]
compress: false
# The password comes from PGPASSWORD (libpq reads it), so no credential
# is written into this file.
database:
name: psycopg2
args:
host: mx-pg-rw
port: 5432
dbname: synapse
user: synapse
cp_min: 2
cp_max: 10
# Signing key is generated on first start if missing; it lives on the
# data PVC next to the media store, so Velero backs up both.
signing_key_path: /data/signing.key
media_store_path: /data/media_store
max_upload_size: 100M
# Also the macaroon key: Synapse falls back to it when
# macaroon_secret_key is unset, so rotating it logs every session out.
registration_shared_secret_path: /secrets/registration_shared_secret
# MAS handles login, sessions and accounts. Synapse asks it to check
# every access token; password login here must be off.
matrix_authentication_service:
enabled: true
endpoint: http://mas:8080/
secret_path: /secrets/mas_matrix_secret
password_config:
enabled: false
# Sign in with QR code (Element X): the rendezvous channel between the
# new and the signed-in device. Both require MAS. `open` lets the new,
# not-yet-signed-in device create the channel; the server is LAN-only.
experimental_features:
msc4108_enabled: true
msc4388_mode: open
# Federation off.
federation_domain_whitelist: []
trusted_key_servers: []
allow_public_rooms_over_federation: false
enable_registration: false
allow_guest_access: false
url_preview_enabled: false
user_directory:
search_all_users: true
# Phones get pushes through the Element push gateway on matrix.org.
# Only event and room IDs leave the cluster, never message text.
push:
include_content: false
# Appservice registrations (bridges), one file each, mounted from a
# Secret. matrix-bridge: bridge.yaml (REN-587).
app_service_config_files:
- /appservices/matrix-bridge/registration.yaml
---
# Media store and the server signing key. Not reconstructable: lost media
# is gone, and a lost signing key invalidates the server's past signatures.
# Backed up by Velero (matrix-daily); the database by Barman (mx-pg).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: synapse-data
namespace: matrix
labels:
app.kubernetes.io/name: synapse
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: synapse
namespace: matrix
annotations:
configmap.reloader.stakater.com/reload: synapse-config,mas-config
secret.reloader.stakater.com/reload: synapse-secrets,matrix-bridge-tokens
labels:
app.kubernetes.io/name: synapse
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: synapse
template:
metadata:
labels:
app.kubernetes.io/name: synapse
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
initContainers:
# One-time move of the Synapse accounts (owner, password hash,
# sessions) into the MAS database. syn2mas needs Synapse offline;
# this runs before Synapse starts, and Recreate stops the old pod
# first. The marker on the data PVC makes later starts skip it.
# Debug image: the plain one has no shell for the marker check.
- name: migrate-to-mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug
command:
- /busybox/sh
- -ec
- |
if [ -f /data/.syn2mas-done ]; then
echo "syn2mas already done"; exit 0
fi
mas-cli syn2mas migrate \
--config /mas-config/config.yaml \
--synapse-config /config/homeserver.yaml \
--synapse-database-uri postgresql:
touch /data/.syn2mas-done
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
# libpq-style variables for the Synapse database; the URI above
# is empty on purpose.
- name: PGHOST
value: mx-pg-rw
- name: PGUSER
value: synapse
- name: PGDATABASE
value: synapse
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: mx-pg-app
key: password
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: mas-config
mountPath: /mas-config
readOnly: true
- name: secrets
mountPath: /secrets
readOnly: true
- name: pg
mountPath: /pg
readOnly: true
- name: data
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.162.0
# Bypass /start.py: it expects to run as root and template a config
# from env. Default mode is generate-missing-and-run, which creates
# the signing key on first start.
command:
- python
- -m
- synapse.app.homeserver
- --config-path
- /config/homeserver.yaml
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: mx-pg-app
key: password
ports:
- name: http
containerPort: 8008
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
# First start runs every schema migration against an empty
# database; give it up to 10 min.
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
failureThreshold: 60
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 4
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: secrets
mountPath: /secrets
readOnly: true
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: appservice-matrix-bridge
mountPath: /appservices/matrix-bridge
readOnly: true
volumes:
- name: config
configMap:
name: synapse-config
- name: secrets
secret:
secretName: synapse-secrets
items:
- key: registration_shared_secret
path: registration_shared_secret
- key: mas_matrix_secret
path: mas_matrix_secret
- key: mas_encryption
path: mas_encryption
- key: mas_rsa_key
path: mas_rsa_key
- key: mas_ec_key
path: mas_ec_key
- name: mas-config
configMap:
name: mas-config
- name: pg
secret:
secretName: mx-pg-app
items:
- key: password
path: password
- name: data
persistentVolumeClaim:
claimName: synapse-data
- name: tmp
emptyDir: {}
- name: appservice-matrix-bridge
secret:
secretName: matrix-bridge-tokens
items:
- key: registration.yaml
path: registration.yaml
---
apiVersion: v1
kind: Service
metadata:
name: synapse
namespace: matrix
spec:
selector:
app.kubernetes.io/name: synapse
ports:
- name: http
port: 8008
targetPort: http
@@ -1,71 +1,4 @@
---
# 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 renders with prometheus.serviceMonitor.enabled: same
@@ -89,11 +22,13 @@ 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.
# The headless `cilium-agent` Service that the HelmRelease renders with
# prometheus.metricsService (REN-166). It carries k8s-app: cilium, which
# targetLabels below copies onto every series; the dashboard the chart
# ships queries cilium_bpf_map_pressure{k8s_app="cilium"}.
matchLabels:
app.kubernetes.io/managed-by: homelab
app.kubernetes.io/name: cilium-agent
app.kubernetes.io/part-of: cilium
namespaceSelector:
matchNames:
- kube-system
@@ -29,6 +29,17 @@
# 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.
#
# NO TLS AND NO AUTH ON THIS SCRAPE, and that is the chart's posture rather than
# an omission here. In 2.11.0 `metrics.listen.secure.enabled` and
# `metrics.listen.auth.enabled` are both false in the chart's values.yaml and
# infrastructure/external-secrets/helmrelease.yaml overrides neither, so the
# controller serves /metrics over plain HTTP to anything that can reach :8080.
# Hence no `scheme`, no `tlsConfig` and no `bearerTokenSecret` below: adding
# them would break the scrape, not harden it. What limits exposure is the
# network policy — a single port from a single namespace — so turning on
# `secure.enabled` here would mean a Helm upgrade of ESO to close a hole the
# CiliumNetworkPolicy already closes.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
@@ -15,6 +15,11 @@ 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
@@ -72,8 +77,7 @@ resources:
# customResourceState block in apps/monitoring/helmrelease-prometheus.yaml —
# these two files must land and be reverted together.
- prometheusrule-flux.yaml
# Gitea's data volume. It filled twice (#336, REN-580) and the chart's
# KubePersistentVolumeFillingUp warned 33 minutes before the second (REN-583).
- prometheusrule-gitea.yaml
- probes-blackbox.yaml
# TEMPORARY, REN-140. Negative-test probes for the REN-28 §7 bar, taking the
# "reachable before" reading that stops being takeable once the paperless PR
# merges. Delete this line and the file once the "after" reading is recorded.
- probes-ren28-negative.yaml
@@ -222,16 +222,16 @@ spec:
# this is the other half of the pair.
#
# That gatus comment says the in-cluster probe "needs a
# CiliumNetworkPolicy first". That sentence is simply WRONG, not
# merely inapplicable:
# ../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit
# already admits the whole monitoring namespace, which is where
# blackbox-exporter runs,
# and Cilium unions the allow rules of every CNP selecting an
# 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). The EGRESS paragraph in the block above applies here
# identically. Nothing in the paperless namespace is edited by this
# change.
# 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: |`
@@ -261,6 +261,32 @@ spec:
# 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/
# anchor: shelly-manager-app-health
# APP-HEALTH probe for shelly-manager (REN-531/REN-533). Same rule as
# the blocks above (monitoring-vantage-point-rule): shelly.renner.uno
# sits behind the shelly-manager-gate outpost, so only the in-cluster
# Service can tell a dead app from a live gate.
#
# /health is unauthenticated and answers a plain 200 — not a 3xx —
# per the app's own test (shelly-manager repo,
# AuthenticationTests.Health_IsUnauthenticated); it is also the pod's
# kubelet liveness/readiness path
# (../home-assistant/shelly-manager-deployment.yaml). AllowedHosts is
# "*", so the cluster-local Host header is accepted. Service :80 ->
# container :8080 (../home-assistant/shelly-manager-service.yaml).
#
# NO CiliumNetworkPolicy change: the home-assistant default-deny
# admits the whole monitoring namespace on every port
# (../../infrastructure/network-policies/home-assistant-ns.yaml,
# "Prometheus scrape" rule). If that rule is ever narrowed to
# Prometheus alone, add a blackbox-exporter grant on :8080 in a
# DEDICATED policy selecting shelly-manager — not on the {} selector,
# where :8080 is shared by three apps. The EGRESS paragraph above
# applies too: a future monitoring egress block must list
# home-assistant.
#
# REVERT: delete the target line below.
- http://shelly-manager.home-assistant.svc.cluster.local:80/health
# 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
@@ -324,12 +350,13 @@ spec:
- 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` today and `0` once REN-28's PR 2b lands. That flip is the
# `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.
@@ -353,10 +380,31 @@ spec:
# these to blackbox-http instead would
# page the on-call forever.
#
# The inverted alert that gives these targets teeth — CNPNegativeControlReachable,
# `probe_success{job="blackbox-negative-control"} == 1` — ships in PR 2b, NOT
# here. Between 2a and 2b these controls are SUPPOSED to read 1, so the alert
# would fire on the correct state. Do not add it before the policy change.
# 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.
@@ -386,9 +434,10 @@ spec:
# 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). Neither that rule nor paperless-allow-blackbox
# exists yet at this commit — that is the point, and why this target
# must read 1 now. Service is CNPG-generated from Cluster `pprl-pg`
# 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
@@ -1,100 +0,0 @@
# TEMPORARY — negative-test probes for REN-28 §7. DELETE AFTER THE "AFTER"
# READING IS TAKEN. This file exists to make two of the four §7 negative tests
# measurable without granting `pods/exec` in `monitoring`, which the owner
# declined on REN-140 as a wider grant than the change it would verify.
#
# WHAT IT IS. blackbox-exporter is already an HTTP/TCP prober running in the
# `monitoring` namespace. Pointing it at targets that the REN-28 paperless PR
# is supposed to CLOSE turns "is this path still open?" into a number in
# Prometheus, readable through the existing read-only REN-36 grant. Every
# target below answers today and MUST stop answering after the PR lands. A
# `probe_success` that stays 1 after the merge means the rule does not do what
# §5 claims.
#
# WHY IT DOES NOT PAGE ANYONE. jobName is `blackbox-ren28-negative`, not
# `blackbox-http`. Every rule in prometheusrule-blackbox.yaml is scoped to
# job="blackbox-http" (BlackboxProbeFailing) or absent(...{job="blackbox-http"})
# (BlackboxProbesAbsent), so neither can match these series — before or after.
# TargetDown (general.rules) cannot match either: the scrape target of a Probe
# is blackbox-exporter itself, which answers 200 with probe_success 0 on a
# failed probe, so `up` stays 1 regardless of the result. Verified against the
# live rule set on 2026-09-26. This is deliberate: a monitor that pages for an
# expected failure is worse than no monitor.
#
# NO POLICY CHANGE IS NEEDED TO MAKE THESE WORK TODAY. Ingress: the paperless
# baseline still admits the whole `monitoring` namespace on every port
# (../../infrastructure/network-policies/paperless-ns.yaml#monitoring-admit) —
# that admission is
# exactly what REN-28 removes. Egress: the `monitoring` namespace is in no
# egress enforcement at all; its only two Cilium policies, monitoring-ns.yaml
# and monitoring-alloy-syslog.yaml, are ingress-only. Same dependency the three
# cross-namespace targets in probes-blackbox.yaml already rest on.
#
# TWO POD-IP TARGETS. pprl-pg's :9187 metrics port is on the POD, not on any
# Service — that is why it is scraped by a PodMonitor and not a ServiceMonitor
# (cnpg-podmonitors.yaml). There is no stable name for it, so the literal pod
# IPs are used. They were read live on 2026-09-26; if either pod is rescheduled
# the probe goes red for the wrong reason. BEFORE TRUSTING AN "AFTER" READING,
# re-confirm the IP still belongs to the same pod:
#
# kube_pod_info{namespace="paperless", pod=~"pprl-pg-.*"} -> pod_ip
#
# The pprl-pg-r:5432 target below is the stable-name version of the same
# statement and survives a reschedule; it is on the database port rather than
# the metrics port, so the two are kept together on purpose.
#
# REVERT. Delete this file and its line in kustomization.yaml. Nothing else
# references it; no workload is touched; Prometheus drops the job on the next
# operator reconcile.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: blackbox-ren28-negative
namespace: monitoring
labels:
# probeSelector is NOT loosened on this cluster (helmrelease-prometheus.yaml
# keeps ruleSelector/probeSelector/scrapeConfigSelector strict), so this
# label is load-bearing, not decorative. Without it the Probe is created and
# never discovered — and a test that silently does not run reads exactly
# like a test that passed.
release: kube-prometheus-stack
spec:
jobName: blackbox-ren28-negative
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: tcp_connect
interval: 60s
# 10s matches blackbox-tcp-relays. It is also the discriminator: a Cilium
# deny DROPS the SYN, so a refused path completes in ~1ms and a denied path
# burns the full timeout. probe_duration_seconds distinguishes "nothing is
# listening" from "policy stopped it" — probe_success alone does not.
scrapeTimeout: 10s
targets:
staticConfig:
static:
# REN-28 §7 test 4 — "from a monitoring pod that is not Prometheus, a
# connection to pprl-pg :9187 must time out". blackbox-exporter is
# precisely that pod. Both CNPG instances, because the PR's
# pprl-pg-allow-prometheus rule selects on a label both carry: if the
# selector were wrong in a way that left one instance open, one target
# would tell us and the other would not.
- 10.244.4.166:9187 # pprl-pg-2, node k8s-2
- 10.244.2.220:9187 # pprl-pg-3, node k8s-3
# Same statement on a stable name and the port that actually matters:
# after the PR the only monitoring→pprl-pg allow is Prometheus on
# :9187, so a non-Prometheus monitoring pod must not reach Postgres.
- pprl-pg-r.paperless.svc.cluster.local:5432
# REN-28 §7 test 2, workload dimension — "from blackbox-exporter, a
# paperless target other than the one it is scoped to must time out".
# The test as written in §7 says "a paperless POD port other than
# 8000"; there is no such port. The paperless pod's only other listener
# is the bridge-tls-proxy socat, bound to 127.0.0.1:11143
# (../paperless/paperless.yaml#bridge-tls-proxy), so every other port
# on that pod
# is closed today and answers RST — there is no "reachable before" to
# record and the port half of paperless-allow-blackbox (:8000) cannot
# be tested at all. gotenberg is the half that CAN be: it is one of the
# other eight workloads the endpointSelector:{} baseline currently
# exposes to all of `monitoring`, and the PR's per-workload selector is
# what closes it.
- gotenberg.paperless.svc.cluster.local:3000
@@ -59,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
@@ -11,6 +11,31 @@ spec:
groups:
- name: disk-wear.rules
rules:
# The drives carrying Talos's EPHEMERAL volume (/var), as (node, device)
# in smartctl-exporter's labels. EPHEMERAL sits on retired Ceph drives
# whose contents rebuild, and the owner's call is that they run to
# failure rather than be replaced on wear (REN-419, REN-420). So the two
# wear alerts below skip them. The failure alerts — spare, critical
# warning, media errors — still cover them: run to failure still means
# knowing when it failed.
#
# Resolved from node-exporter's /var mount, not a list of serials, so it
# follows a drive swap without an edit. If the join stops matching (a
# relabel, an encrypted /var on a dm device) the series is empty, and
# the wear alerts return for EPHEMERAL drives: it fails loud, not
# silent.
- record: node_device:nvme_ephemeral_volume:info
expr: >-
count by (node, device) (
label_replace(
label_replace(
node_filesystem_size_bytes{mountpoint="/var"}
* on (instance) group_left (nodename) node_uname_info,
"node", "$1", "nodename", "(.+)"
),
"device", "$1", "device", "/dev/(nvme[0-9]+)n[0-9]+(p[0-9]+)?"
)
)
# Wear percentages are self-reported: 100 means the rated endurance is
# spent, not that the drive stops.
#
@@ -45,6 +70,7 @@ spec:
)
) < 180
and smartctl_device_percentage_used >= 80
unless on (node, device) node_device:nvme_ephemeral_volume:info
# Long, because a projection off a 7d rate should not react to a day.
for: 6h
labels:
@@ -55,13 +81,17 @@ spec:
The drive is past 80% of its rated endurance and the write rate
of the last week would spend the rest within six months. This is
a lead-time warning, not a fault: decide whether to replace the
drive or move what is writing to it. The Disk Health dashboard
drive or move what is writing to it. Drives carrying EPHEMERAL
are excluded; they run to failure. The Disk Health dashboard
shows which drives carry which role.
# Kept as a flat threshold on purpose: past rated endurance the drive
# runs outside what the manufacturer stands behind regardless of how
# fast it got there, and the alert clears by replacing it.
# fast it got there, and the alert clears by replacing it. EPHEMERAL
# drives are excluded for the same reason as above.
- alert: NvmeDriveWearCritical
expr: smartctl_device_percentage_used >= 95
expr: >-
smartctl_device_percentage_used >= 95
unless on (node, device) node_device:nvme_ephemeral_volume:info
for: 1h
labels:
severity: critical
@@ -0,0 +1,42 @@
# Gitea's data volume (data-gitea-0) running low (REN-583).
#
# It filled twice: 2026-07-22 (#336) and 2026-10-03 (REN-580), both times
# because the package registry grew faster than cleanup_packages reclaimed it.
# A full volume fails image pushes (HTTP 500) and Gitea's own SQLite writes.
#
# The chart's KubePersistentVolumeFillingUp did not give usable lead time on
# REN-580: its warning needs under 15% free AND a 4-day linear projection to
# zero AND one hour of both, so it fired at 20:26Z; the volume was full at
# 20:59Z. Growth here comes in steps (a run of image builds added 8 GiB in a
# few hours that day), which a linear projection lags.
#
# So this is a plain floor: under 25% free (15 GiB at 60Gi) for 15m. That is
# about two of the 8 GiB steps seen on REN-580, and time for someone to run the
# package cleanup or grow pvc.yaml in apps/gitea.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: gitea
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: gitea.rules
rules:
- alert: GiteaDataVolumeLow
expr: >-
kubelet_volume_stats_available_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}
/ kubelet_volume_stats_capacity_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}
< 0.25
for: 15m
labels:
severity: warning
annotations:
summary: "Gitea data volume has {{ $value | humanizePercentage }} free"
description: >-
data-gitea-0 is under 25% free. When it fills, image pushes fail
with HTTP 500 and Gitea cannot write its database. Run the
cleanup_packages cron in Gitea's site admin and delete old
package versions, or grow the claim in apps/gitea/pvc.yaml
(ceph-block resizes online).
@@ -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.
@@ -30,6 +30,8 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
| `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). |
| `disk-wear_test.yaml` | Hand-written suite for the EPHEMERAL exclusion in `prometheusrule-disk-wear.yaml` (REN-419). |
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
@@ -0,0 +1,124 @@
# promtool test rules for the EPHEMERAL exclusion in prometheusrule-disk-wear.yaml
# (REN-419).
#
# 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 `disk-wear-*`
# entries in mutations.txt.
#
# WHAT IT IS FOR. The two wear alerts skip the drive that carries /var, found
# by joining node-exporter's /var mount onto smartctl-exporter's (node, device).
# Each case puts an EPHEMERAL drive and a non-EPHEMERAL drive side by side with
# identical SMART data, so the only thing that can separate them is the join.
#
# Fixture shape: smartctl-exporter labels drives `nvme0`, `nvme1` and carries
# `node` from the ServiceMonitor relabel; node-exporter carries `instance` and
# reports /var on the partition, `/dev/nvme1n1p1`.
rule_files:
- ../prometheusrule-disk-wear.yaml
evaluation_interval: 1m
tests:
# A. Projection. k8s-1 nvme0 and nvme1 wear identically: 89%, 1.5e9 bytes
# per 5m (4.32e11 a day), 9e14 lifetime bytes at 192h. That is 0.04272 % a
# day, 6 % to go, 140 days — under 180. Only nvme0 may fire; nvme1
# carries /var. Neither is at 95%, so the critical alert stays quiet.
#
# Sampled at 5m, not 1h: the instant selectors look back 5m, so hourly
# samples leave the series absent for most of each hour and the 6h `for`
# never completes. Not 1m either: a 7d rate over 1m samples exceeds
# promtool's query sample limit.
- interval: 5m
input_series:
- series: 'smartctl_device_percentage_used{node="k8s-1",device="nvme0",instance="10.0.0.1:9633"}'
values: '89+0x2320'
- series: 'smartctl_device_bytes_written{node="k8s-1",device="nvme0",instance="10.0.0.1:9633"}'
values: '896544000000000+1500000000x2320'
- series: 'smartctl_device_percentage_used{node="k8s-1",device="nvme1",instance="10.0.0.1:9633"}'
values: '89+0x2320'
- series: 'smartctl_device_bytes_written{node="k8s-1",device="nvme1",instance="10.0.0.1:9633"}'
values: '896544000000000+1500000000x2320'
- series: 'node_filesystem_size_bytes{instance="10.0.0.1:9100",mountpoint="/var",device="/dev/nvme1n1p1",fstype="xfs"}'
values: '1000000000000+0x2320'
- series: 'node_uname_info{instance="10.0.0.1:9100",nodename="k8s-1"}'
values: '1+0x2320'
alert_rule_test:
- eval_time: 192h
alertname: NvmeDriveWearProjectedExhaustion
exp_alerts:
- exp_labels:
severity: warning
node: k8s-1
device: nvme0
instance: 10.0.0.1:9633
exp_annotations:
summary: "NVMe nvme0 on 10.0.0.1:9633 projects to 95% wear in 140 days"
description: "The drive is past 80% of its rated endurance and the write rate of the last week would spend the rest within six months. This is a lead-time warning, not a fault: decide whether to replace the drive or move what is writing to it. Drives carrying EPHEMERAL are excluded; they run to failure. The Disk Health dashboard shows which drives carry which role."
- eval_time: 192h
alertname: NvmeDriveWearCritical
exp_alerts: []
# B. Critical, two nodes. Every drive is at 96%. On k8s-1 /var is nvme1; on
# k8s-2 it is nvme2, which reports no SMART here. So k8s-1 nvme1 is the only
# one excluded, and k8s-2 nvme1 must still fire — the exclusion is per
# node, not per device name.
- interval: 1m
input_series:
- series: 'smartctl_device_percentage_used{node="k8s-1",device="nvme0",instance="10.0.0.1:9633"}'
values: '96+0x200'
- series: 'smartctl_device_percentage_used{node="k8s-1",device="nvme1",instance="10.0.0.1:9633"}'
values: '96+0x200'
- series: 'smartctl_device_percentage_used{node="k8s-2",device="nvme1",instance="10.0.0.2:9633"}'
values: '96+0x200'
- series: 'node_filesystem_size_bytes{instance="10.0.0.1:9100",mountpoint="/var",device="/dev/nvme1n1p1",fstype="xfs"}'
values: '1000000000000+0x200'
- series: 'node_uname_info{instance="10.0.0.1:9100",nodename="k8s-1"}'
values: '1+0x200'
- series: 'node_filesystem_size_bytes{instance="10.0.0.2:9100",mountpoint="/var",device="/dev/nvme2n1p1",fstype="xfs"}'
values: '1000000000000+0x200'
- series: 'node_uname_info{instance="10.0.0.2:9100",nodename="k8s-2"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: NvmeDriveWearCritical
exp_alerts:
- exp_labels:
severity: critical
node: k8s-1
device: nvme0
instance: 10.0.0.1:9633
exp_annotations:
summary: "NVMe nvme0 on 10.0.0.1:9633 is at 96% rated wear"
description: "The drive is at or past the end of its rated endurance. It will likely keep working for a while, but it is now operating outside what the manufacturer stands behind, and replacement should be scheduled rather than considered."
- exp_labels:
severity: critical
node: k8s-2
device: nvme1
instance: 10.0.0.2:9633
exp_annotations:
summary: "NVMe nvme1 on 10.0.0.2:9633 is at 96% rated wear"
description: "The drive is at or past the end of its rated endurance. It will likely keep working for a while, but it is now operating outside what the manufacturer stands behind, and replacement should be scheduled rather than considered."
# C. Fails loud. /var is on a device the join cannot map to an NVMe name (an
# encrypted volume on dm-0). The exclusion then matches nothing and the
# EPHEMERAL drive alerts again, which is the intended direction to fail.
- interval: 1m
input_series:
- series: 'smartctl_device_percentage_used{node="k8s-1",device="nvme1",instance="10.0.0.1:9633"}'
values: '96+0x200'
- series: 'node_filesystem_size_bytes{instance="10.0.0.1:9100",mountpoint="/var",device="/dev/dm-0",fstype="xfs"}'
values: '1000000000000+0x200'
- series: 'node_uname_info{instance="10.0.0.1:9100",nodename="k8s-1"}'
values: '1+0x200'
alert_rule_test:
- eval_time: 120m
alertname: NvmeDriveWearCritical
exp_alerts:
- exp_labels:
severity: critical
node: k8s-1
device: nvme1
instance: 10.0.0.1:9633
exp_annotations:
summary: "NVMe nvme1 on 10.0.0.1:9633 is at 96% rated wear"
description: "The drive is at or past the end of its rated endurance. It will likely keep working for a while, but it is now operating outside what the manufacturer stands behind, and replacement should be scheduled rather than considered."
@@ -0,0 +1,46 @@
# Suite for prometheusrule-gitea.yaml (REN-583). Capacity 60 GiB; free space
# in GiB. 15 GiB free is exactly 25% and must stay silent.
rule_files:
- ../prometheusrule-gitea.yaml
evaluation_interval: 1m
tests:
# 20 GiB free, then 14 GiB free from minute 10 on: pending at 10m, fires
# at 25m (15m later), not before.
- interval: 1m
input_series:
- series: 'kubelet_volume_stats_capacity_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}'
values: '64424509440+0x40'
- series: 'kubelet_volume_stats_available_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}'
values: '21474836480+0x9 15032385536+0x30'
alert_rule_test:
- eval_time: 24m
alertname: GiteaDataVolumeLow
exp_alerts: []
- eval_time: 25m
alertname: GiteaDataVolumeLow
exp_alerts:
- exp_labels:
severity: warning
namespace: gitea
persistentvolumeclaim: data-gitea-0
exp_annotations:
summary: "Gitea data volume has 23.33% free"
description: "data-gitea-0 is under 25% free. When it fills, image pushes fail with HTTP 500 and Gitea cannot write its database. Run the cleanup_packages cron in Gitea's site admin and delete old package versions, or grow the claim in apps/gitea/pvc.yaml (ceph-block resizes online)."
# Exactly 25% free stays silent; another claim at 1% free is not this alert.
- interval: 1m
input_series:
- series: 'kubelet_volume_stats_capacity_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}'
values: '64424509440+0x40'
- series: 'kubelet_volume_stats_available_bytes{namespace="gitea",persistentvolumeclaim="data-gitea-0"}'
values: '16106127360+0x40'
- series: 'kubelet_volume_stats_capacity_bytes{namespace="other",persistentvolumeclaim="data-other-0"}'
values: '64424509440+0x40'
- series: 'kubelet_volume_stats_available_bytes{namespace="other",persistentvolumeclaim="data-other-0"}'
values: '644245094+0x40'
alert_rule_test:
- eval_time: 40m
alertname: GiteaDataVolumeLow
exp_alerts: []
@@ -141,3 +141,34 @@ ceph-blind-inverted|kubernetes/apps/monitoring-rules/prometheusrule-ceph.yaml|s/
# 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])/
# prometheusrule-disk-wear.yaml (REN-419): the EPHEMERAL exclusion on the two
# wear alerts. Detected by the cases in disk-wear_test.yaml.
#
# Gate inverted on the projection: the EPHEMERAL drive alone fires.
# Detected by: case A.
disk-wear-projection-gate-inverted|kubernetes/apps/monitoring-rules/prometheusrule-disk-wear.yaml|/alert: NvmeDriveWearProjectedExhaustion/,/for: 6h/s/unless on (node, device)/and on (node, device)/
# Gate inverted on the critical rule.
# Detected by: case B.
disk-wear-critical-gate-inverted|kubernetes/apps/monitoring-rules/prometheusrule-disk-wear.yaml|/alert: NvmeDriveWearCritical/,/for: 1h/s/unless on (node, device)/and on (node, device)/
# Matched on device name only: k8s-1's /var on nvme1 then silences nvme1 on
# every node.
# Detected by: case B, k8s-2 nvme1.
disk-wear-critical-device-only|kubernetes/apps/monitoring-rules/prometheusrule-disk-wear.yaml|/alert: NvmeDriveWearCritical/,/for: 1h/s/unless on (node, device)/unless on (device)/
# Partition suffix not stripped: /dev/nvme1n1p1 no longer maps to nvme1 and the
# exclusion matches nothing.
# Detected by: cases A and B.
disk-wear-ephemeral-keeps-partition|kubernetes/apps/monitoring-rules/prometheusrule-disk-wear.yaml|s/n\[0-9\]+(p\[0-9\]+)?"/n[0-9]+"/
# prometheusrule-gitea.yaml (REN-583). Detected by the 24m/25m pair and the
# exactly-25% case in gitea_test.yaml.
gitea-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|/alert: GiteaDataVolumeLow/,/for:/s/for: 15m/for: 10m/
gitea-floor-le|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|s/^\([[:space:]]*\)< 0\.25$/\1<= 0.25/
@@ -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."
@@ -17,7 +17,7 @@ spec:
chart:
spec:
chart: kube-prometheus-stack
version: "91.7.0"
version: "91.9.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:
@@ -10,6 +10,7 @@ resources:
- loki-basic-auth-externalsecret.yaml
- loki-basic-auth-middleware.yaml
- loki-ingress.yaml
- loki-read-gateway.yaml
- alertmanager-gate-service.yaml
- alertmanager-gate-ingress.yaml
- prometheus-gate-service.yaml
@@ -18,6 +19,13 @@ resources:
- smartctl-exporter.yaml
- intel-gpu-exporter.yaml
- dashboards/
configMapGenerator:
# Hash suffix kept on purpose: an edit to the .conf rolls the gateway pod,
# which is stateless and off the ingest path.
- name: loki-read-gateway
namespace: monitoring
files:
- loki-read-gateway.conf
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
# server-side dry-run of the PrometheusRule fails on a fresh cluster
@@ -0,0 +1,60 @@
# GET-only allow-list in front of Loki (REN-621, owner decision on REN-622).
#
# Loki runs SingleBinary with auth_enabled: false, and its HTTP port mixes
# reads with control endpoints that also answer GET (/ingester/shutdown,
# /flush). An RBAC `get` on services/proxy for loki:3100 would therefore be a
# kill switch for log ingest. This server is the read-only boundary instead:
# the paperclip read ServiceAccount may proxy to this Service only.
#
# Each allowed location forwards a URI built here, never the client's raw
# request URI, so dot-segments or encoded slashes cannot reach another Loki
# path. Anything not listed gets 403. /loki/api/v1/tail (websocket) is left
# out on purpose. Adding a path is a new card and a new review.
upstream loki {
server loki.monitoring.svc.cluster.local:3100;
}
server {
listen 8080;
server_tokens off;
proxy_http_version 1.1;
proxy_set_header Host loki;
proxy_read_timeout 60s;
location / {
return 403;
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = /loki/api/v1/query {
limit_except GET { deny all; }
proxy_pass http://loki/loki/api/v1/query$is_args$args;
}
location = /loki/api/v1/query_range {
limit_except GET { deny all; }
proxy_pass http://loki/loki/api/v1/query_range$is_args$args;
}
location = /loki/api/v1/labels {
limit_except GET { deny all; }
proxy_pass http://loki/loki/api/v1/labels$is_args$args;
}
location = /loki/api/v1/series {
limit_except GET { deny all; }
proxy_pass http://loki/loki/api/v1/series$is_args$args;
}
location ~ ^/loki/api/v1/label/(?<label_name>[A-Za-z_][A-Za-z0-9_]*)/values$ {
limit_except GET { deny all; }
proxy_pass http://loki/loki/api/v1/label/$label_name/values$is_args$args;
}
}
@@ -0,0 +1,97 @@
---
# Read-only Loki gateway for the agents in the paperclip pod (REN-621,
# owner decision on REN-622). The allow-list and the reason it exists are in
# loki-read-gateway.conf. Callers reach it through the apiserver proxy only:
# /api/v1/namespaces/monitoring/services/loki-read-gateway:8080/proxy/loki/api/v1/query_range?...
# granted by ../paperclip/prometheus-read-rbac.yaml.
#
# Stateless and off the ingest path: Alloy pushes straight to the `loki`
# Service, so restarting or deleting this pod never touches log ingest.
# Revert: delete this file, the .conf and their kustomization entries, and the
# resourceName in the paperclip Role.
apiVersion: apps/v1
kind: Deployment
metadata:
name: loki-read-gateway
namespace: monitoring
labels:
app.kubernetes.io/name: loki-read-gateway
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: loki-read-gateway
template:
metadata:
labels:
app.kubernetes.io/name: loki-read-gateway
spec:
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:26b0bf6fbf07297983cb341998d79c831508787de26627dd2a112321b9c3a4af
ports:
- name: http
containerPort: 8080
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
volumeMounts:
# Replaces the image's default.conf; the entrypoint's ipv6 and
# envsubst steps skip a read-only conf.d and the server starts.
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
# pid file and proxy temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: loki-read-gateway
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 32Mi
---
apiVersion: v1
kind: Service
metadata:
name: loki-read-gateway
namespace: monitoring
labels:
app.kubernetes.io/name: loki-read-gateway
spec:
selector:
app.kubernetes.io/name: loki-read-gateway
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
@@ -55,7 +55,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.4 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.5 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -65,7 +65,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.4 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.5 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info

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