Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 50cf3e977a docs(paperclip): align spec with implementation and review fixes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:22:43 +02:00
claude-botandClaude Opus 5.5 bb3a923c43 fix(paperclip): probes pass the hostname guard; tighten egress and SA token
- Paperclip's private-hostname guard (authenticated+private) returns 403
  for any Host but loopback and the public URL. Kubelet probes send the
  pod IP, so the pod could never become Ready. Probes now send
  Host: localhost.
- Liveness is a TCP check: /api/health runs SELECT 1, so a Postgres
  outage would have restarted the server and killed in-flight runs.
- Baseline CNP allowed world on every port; Cilium unions allows across
  CNPs, so the app-level :443 restriction was not enforced. world is
  now :443-only in the baseline as well.
- automountServiceAccountToken: false. Every agent shares the pod
  filesystem; bots bring their own cluster credentials.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:23:29 +02:00
k.renner 161c04daff Merge pull request 'feat(rook-ceph): allow only aes256k CephX keys (#527)' (#935) from feat/cephx-allowed-ciphers-aes256k into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#935
2026-09-23 19:15:33 +00:00
claude-botandClaude Opus 5.5 999e3fbac9 feat(rook-ceph): allow only aes256k CephX keys (#527)
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
Final step of the CVE-2025-30156 remediation: allowedCiphers
[aes256k], which Rook applies as auth_allowed_ciphers. That is step 10
of Ceph's CephX aes256k upgrade procedure and clears
AUTH_INSECURE_KEYS_ALLOWED; per Ceph's health-check reference that also
resolves AUTH_INSECURE_KEYS_CREATABLE.

Precondition from the procedure, checked without printing any key:
AUTH_INSECURE_CLIENT_KEY_TYPE and AUTH_INSECURE_SERVICE_KEY_TYPE are both
clear (ceph health detail lists only KEYS_ALLOWED and KEYS_CREATABLE).
Rook status: daemon keys and CSI/rbdMirrorPeer at generation 2, prior CSI
keys deleted (#934).

Lockout recovery if auth breaks (Rook cephx-key-rotation doc): set
allowedCiphers [aes, aes256k] and daemon.keyType aes, wait for the mons
to carry --mon-auth-emergency-allowed-ciphers, then remove
daemon.keyType.

Closes #527

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:12:43 +02:00
k.renner 70208c42da Merge pull request 'feat(rook-ceph): delete the prior aes CSI keys (#527)' (#934) from feat/cephx-drop-prior-csi-keys into master
Reviewed-on: k.renner/homelab#934
2026-09-23 19:04:44 +00:00
claude-botandClaude Opus 5.5 94b35c3f64 feat(rook-ceph): delete the prior aes CSI keys (#527)
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
keepPriorKeyCountMax 1 -> 0. Rook deletes the generation-1 CSI keys
(client.csi-*.1, aes), which clears AUTH_INSECURE_CLIENT_KEY_TYPE.

Every PVC has been remounted with the generation-2 aes256k keys: all
three nodes were drained and uncordoned after the rotation, and no
kernel session on any mon uses a .1 entity any more. The remaining .1
sessions are idle librados connections in the CSI nodeplugins' pool;
ceph-csi keys pool entries by user and keyfile, and the CSI secrets now
name the .2 users, so nothing reuses them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:04:04 +02:00
k.renner 5cbdb6e67f Merge pull request 'ci(wiki-maintenance): allow the ~/Notes spelling the agents actually use' (#932) from ci/wiki-consolidate-tilde-paths into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/manual/wiki-maintenance Pipeline failed
Reviewed-on: k.renner/homelab#932
2026-09-23 18:56:45 +00:00
claude-botandClaude Fable 5.1 ee08edba3e ci(wiki-maintenance): allow the ~/Notes spelling the agents actually use
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
Run 10608 aborted before its first edit: the agent calls
~/Notes/bin/wiki-snapshot.sh and git -C ~/Notes rev-parse/status, the
rules carried only /root/Notes. Both spellings are allowed for the vault
scripts and the read-only git subcommands on the vault checkout, config is
denied there too, and the consolidate step derives its --allowedTools
from the settings file instead of a second copy. Verified headless:
rev-parse, status and wiki-delete.sh allowed, config denied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 20:53:23 +02:00
k.renner 96d15f19c8 Merge pull request 'ci(wiki-maintenance): run the consolidate pass in CI' (#927) from ci/wiki-consolidate-in-ci into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
Reviewed-on: k.renner/homelab#927
2026-09-23 18:48:21 +00:00
k.renner 0581fd461f Merge pull request 'feat(rook-ceph): rotate CSI and rbd-mirror-peer keys to aes256k (#527)' (#931) from feat/cephx-csi-aes256k into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#931
2026-09-23 18:24:43 +00:00
claude-botandClaude Opus 5.5 f1024fa4b3 feat(rook-ceph): rotate CSI and rbd-mirror-peer keys to aes256k (#527)
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 last five CephX entities on aes are the four CSI clients and
client.rbd-mirror-peer. The CSI keys waited on node kernel support:
Rook's key-rotation doc asks for kernel 7.0+ for aes256k over kernel
mounts. Talos v1.13.10 (now on all nodes) backports those libceph
patches to 6.18 (siderolabs/pkgs 2da2cdc); CONFIG_CRYPTO_KRB5 and
CONFIG_CRYPTO_KRB5ENC are set on every node. ceph-csi is v3.17.1, the
minimum the doc names; Rook v1.20.7.

csi: KeyGeneration 2, keyType aes256k, keepPriorKeyCountMax 1. Rotated
keys only apply to new mounts; the prior key stays valid for PVCs
mounted before the rotation, per the Rook doc.

rbdMirrorPeer: KeyGeneration 2, aes256k. No CephRBDMirror and no peers
exist, so rotating the peer token affects nothing.

allowedCiphers / mon_auth_allow_insecure_key are NOT changed here: that
is the lockout-capable step and follows only once ceph health shows no
aes entity left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:20:52 +02:00
claude-botandClaude Fable 5.1 bd4553cc63 ci(wiki-maintenance): report the claude exit code when no result record exists
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 on #927: an empty or truncated /tmp/consolidate.json made jq the
failing command instead of the run. Commit subject of push-notes names
consolidate too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 20:19:45 +02:00
k.renner 904475b89a Merge pull request 'chore(talos): move to v1.13.10' (#930) from chore/talos-1.13.10 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#930
2026-09-23 18:18:14 +00:00
claude-botandClaude Opus 5.5 c395a9fc67 chore(talos): move to v1.13.10
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
All three nodes run v1.13.10 (applied by hand, one node at a time:
drain, talosctl upgrade --image, wait for 217/217 active+clean). This
brings the repo in step so a rebuild or a new node installs the same
version.

Why this release: its pkgs include siderolabs/pkgs@2da2cdc, 'backport
aes256k support (Ceph)', the libceph patches from Linux 7.x on top of
6.18.48. Kernel config on every node now has CONFIG_CRYPTO_KRB5=y and
CONFIG_CRYPTO_KRB5ENC=y. That was the blocker for rotating the CSI
CephX keys to aes256k (#527).

Patch release within 1.13; Kubernetes stays v1.36.3. Schematic is
unchanged; factory installer and ISO verified to exist for the tag.
talosctl checksum matches the release's sha256sum.txt and a local
download.

Refs #527

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 19:26:53 +02:00
k.renner bf3d58628e Merge pull request 'feat(k8s): make single-instance workloads survive node drains' (#929) from feat/drain-tolerant-singletons into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#929
2026-09-23 17:07:42 +00:00
claude-botandClaude Opus 5.5 6edde49b48 feat(k8s): make single-instance workloads survive node drains
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
Draining k8s-3 for the Talos 1.13.10 upgrade stopped on four pods
(wp-pg-1, ha-pg-1, gf-pg-1, gitea-0) whose PDBs allow zero disruptions,
and each needed a manual --disable-eviction.

CNPG: ha-pg, gf-pg, wp-pg and tr-pg go to instances: 2. With a replica,
CNPG switches the primary over ahead of a drain, the same way ak-pg,
im-pg and pprl-pg already do. With one instance it creates a primary PDB
and 'will prevent draining the node where the instance is housed'
(CNPG docs, kubernetes_upgrade). smartShutdownTimeout 10 / stopDelay 60
as on ak-pg, which bound switchover time and the barman-plugin demote
hang (cloudnative-pg#9770). Network policies already allow intra-cluster
replication, operator :8000 and WAL-restore egress in all four
namespaces; nothing references a specific instance name.

Single-replica RWO workloads (gitea, woodpecker-server,
protonmail-bridge, syncthing, influxdb): drop the minAvailable:1 PDBs.
They cannot fail over, and eviction already stops the pod gracefully
with SIGTERM and its grace period; the PDB only forced a manual
--disable-eviction on every drain. influxdb's comes from the chart
(pdb.create, influxdb2 2.1.2 defaults).

The 'single-instance cluster, the archive is the only off-node copy'
comments no longer hold and are removed (im-pg's was already stale).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 19:06:16 +02:00
claude-botandClaude Fable 5.1 94b6d77bac ci(wiki-maintenance): per-subcommand git allow for the source clones, deletion via wiki-delete.sh
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 on #927: the git -C /root/source/ prefix let config and push
through; rm /root/Notes/wiki/ matched path traversal. Allowed git forms
are now log/show/diff/status/rev-parse/ls-files per repo (both the ~ and
/root spelling), config is denied for those forms too, rm is denied and
notes are deleted through the vault's wiki-delete.sh, which validates the
slug. Verified headless: log allowed, config denied, push not allowed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 19:04:24 +02:00
claude-botandClaude Fable 5.1 c7f6514e20 ci(wiki-maintenance): run the consolidate pass in CI
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 pass that verifies notes against sources, repos and the live cluster
and edits them still ran on the workstation timer, with no TypeSafe key,
no cost record, and no run on days the machine was off. It joins the
nightly pipeline after the audit: full clones of homelab and homeassistant
under ~/source, read-only kubectl via the ci-diagnostics token, its own
permission scope, JSON output so the report and total_cost_usd land in
the step log. push-notes also commits the rotation state and pushes the
agent's own snapshot commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 18:22:02 +02:00
claude-botandClaude Fable 5.1 d865043eaa ci(wiki-maintenance): skip the distill agent when raw/ is empty
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline is running
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Every night with an empty queue started a full claude run that read the
directory and stopped, paying the agent's startup context and a model
pick for nothing (runs 10362, 10583).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 18:12:02 +02:00
k.renner 47fbc917fd Merge pull request 'ci(wiki-maintenance): run the audit as the vault's script, commit only vault content' (#925) from ci/wiki-audit-script into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#925
2026-09-23 16:06:52 +00:00
claude-botandClaude Fable 5.1 de1530789e ci(wiki-maintenance): run the audit as the vault's script, commit only vault content
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 audit agent had no executable path in CI (Bash limited to the two
retrieval tools), wrote itself scripts it could not run and reported one
of seven checks (runs 10262, 10362). The checks are mechanical, so
notes/bin/wiki_audit.py does them without a model. push-notes adds only
wiki/, raw/ and output/, so scratch files in the checkout root are no
longer committed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 18:05:41 +02:00
k.renner c92c1f4ab5 Merge pull request 'fix(k8s-gateway): pull the chart from the Codeberg OCI registry' (#924) from fix/k8s-gateway-oci-chart into master
Reviewed-on: k.renner/homelab#924
2026-09-23 16:01:24 +00:00
k.renner 6bb8ef4776 Merge pull request 'fix: [alert] OpenBaoQuorumLost (openbao) (#920)' (#921) from alert-fix/issue-920 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#921
2026-09-23 15:59:33 +00:00
claude-botandClaude Opus 5.5 33a0079a20 fix(k8s-gateway): pull the chart from the Codeberg OCI registry
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 project moved to Codeberg; GitHub is a read-only mirror and the old
chart index at k8s-gateway.github.io returns 404, so the HelmRelease has
had no artifact. The chart is now published as an OCI artifact in the
project's own Codeberg registry (oci://codeberg.org/k8s-gateway/charts/
k8s-gateway), alongside the image.

OCIRepository + chartRef as for spegel and mariadb-operator; semver
range unchanged. Chart 3.7.2 -> 3.7.3, image moves to
codeberg.org/k8s-gateway/k8s_gateway:1.8.3. The rendered Corefile is
identical to the live one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:57:20 +02:00
k.renner 80816d7433 Merge pull request 'docs(network): traffic classes as built, node-to-node on VLAN 46 (#576)' (#923) from docs/network-traffic-classes-as-built into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#923
2026-09-23 12:49:20 +00:00
claude-botandClaude Opus 5.5 32a995a6bf docs(network): traffic classes as built, node-to-node on VLAN 46 (#576)
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
NETWORK-traffic-classes.md described table 48 and rules 980/981/990/991,
which #857 replaced with one table (47) decided by longest prefix, and
listed management as not started. Rewritten to the live state: five
classes, table 47 contents owned by bird, node IP and etcd on node0, the
etcd leave-and-rejoin procedure, and the mixed-state hazard hit during
the node IP move. The bird DaemonSet comment still named table 48.

Closes #576

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:48:12 +02:00
k.renner 8a0ed135f8 Merge pull request 'feat(talos): node IP and etcd on VLAN 46 (#576 step 3)' (#922) from feat/node-ip-vlan-46 into master
Reviewed-on: k.renner/homelab#922
2026-09-23 12:47:06 +00:00
claude-botandClaude Opus 5.5 be643533f6 feat(talos): node IP and etcd on VLAN 46 (#576 step 3)
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
Records the live state after moving all three nodes:
- site-homelab.yaml: kubelet nodeIP.validSubnets fd02:46::/64 and
  10.2.46.0/24 (was 10.2.40.0/24 and the legacy fd40::/64).
- cluster.yaml: etcd advertisedSubnets 10.2.46.0/24.
- mgmt0 comments: VLAN 40 is world-to-node only now.

Applied per node (k8s-3, k8s-2, k8s-1): drain, stage the config (delete
the old lists, set the new ones, one staged patch call), etcd
forfeit-leadership and leave, reboot. Each node rejoined etcd with its
VLAN 46 peer URL; EPHEMERAL was not wiped.

Refs #576

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:43:17 +02:00
issue-fix-bot 87083c46e7 fix: [alert] OpenBaoQuorumLost (openbao) (#920)
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-09-23 12:42:58 +00:00
k.renner 311b4639a6 Merge pull request 'feat(bird): put node0 in the pod routing table (#576 step 2)' (#919) from feat/bird-attached-node0 into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#919
2026-09-23 11:31:32 +00:00
k.renner a95efdef33 Merge pull request 'feat(talos): node network on VLAN 46, etcd pinned to VLAN 40 (#576 step 1)' (#918) from feat/node-vlan-46-link into master
Reviewed-on: k.renner/homelab#918
2026-09-23 11:31:23 +00:00
claude-botandClaude Opus 5.5 aea4be734c feat(bird): put node0 in the pod routing table (#576 step 2)
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
Pod-sourced traffic is routed from table 47. Once the node IP moves to
VLAN 46 (step 3), pods reach node addresses there: the kubernetes
ClusterIP (socket LB rewrites it to the apiserver's advertise address),
hostNetwork pod IPs, and replies to kubelet probes. Without the attached
node0 route in table 47 those flows take the default to the UDM and every
in-cluster API client breaks. Taking it from the interface, like the
other attached networks, keeps the table in one owner.

Refs #576

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:28:57 +02:00
claude-botandClaude Opus 5.5 07dd6097ec feat(talos): node network on VLAN 46, etcd pinned to VLAN 40 (#576 step 1)
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
- node0: VLANConfig on the uplink trunk, VLAN 46, static fd02:46::1x/64
  and 10.2.46.1x/24, accept_ra 0. Closed like pod0. Unused until the node
  IP moves there (step 3).
- cluster.etcd.advertisedSubnets pinned to 10.2.40.0/24 so a reboot
  cannot pick a node0 address: Talos never updates an existing member's
  peer URLs.

Applied live with talosctl patch --mode=no-reboot, k8s-3 first. Node IPs
unchanged, etcd 3/3 healthy, node0 reachable node to node over v6 and v4.

Refs #576

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:25:12 +02:00
k.renner 60361d4c8a Merge pull request 'chore(talos): drop the #832 stage labels' (#917) from chore/cni-drop-stage-labels into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#917
2026-09-23 10:46:35 +00:00
claude-botandClaude Opus 5.5 a8cef5de1b chore(talos): drop the #832 stage labels
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing selects cni.renner.uno/cilium-uninstall or
cni.renner.uno/multus-sole-writer since stage B (#915, #916). Removed
live from all three nodes with a $patch: delete strategic merge patch
(--mode=no-reboot); Talos dropped them from the Kubernetes nodes and
left the other labels in place.

Closes #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:40:10 +02:00
k.renner 505802dad5 Merge pull request 'feat(cilium): write the conflist where only Multus reads it (stage B2 of #832)' (#916) from feat/cni-sole-writer-converge-b2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#916
2026-09-23 10:38:00 +00:00
claude-botandClaude Opus 5.5 ba66b604d4 feat(cilium): write the conflist where only Multus reads it (stage B2 of #832)
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
cni.confPath: /etc/cni/net.d/multus mounts that host directory as the
agent's /host/etc/cni/net.d, so write-cni-conf-when-ready (unchanged,
.../05-cilium.conflist) lands in /etc/cni/net.d/multus/05-cilium.conflist,
the file the per-node CiliumNodeConfigs already produced on all three
nodes. The agent finds it unchanged and does not rewrite it.

updateStrategy maxUnavailable: 1 (chart default 2) so the roll takes one
node's datapath at a time.

bootstrap.sh renders the chart from these values, so a cold rebuild
lands directly in this layout.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:55:12 +02:00
k.renner 4fbc49fc8c Merge pull request 'feat(cni): converge Multus as sole CNI writer, part 1 (stage B1 of #832)' (#915) from feat/cni-sole-writer-converge-b1 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#915
2026-09-23 09:53:24 +00:00
claude-botandClaude Opus 5.5 f493c9fe52 feat(cni): converge Multus as sole CNI writer, part 1 (stage B1 of #832)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
All three nodes run the sole-writer layout. Converge the Multus side and
drop the per-node Cilium overrides:
- kube-multus-ds-subdir becomes the only Multus DaemonSet, no node
  affinity; kube-multus-ds (0 pods) and its ConfigMap are pruned.
- /etc/cni/net.d/multus is mounted DirectoryOrCreate so Multus waits for
  the Cilium conflist instead of exiting on a missing directory.
- CiliumNodeConfigs removed. They only act when a cilium pod is created;
  stage B2 moves the same path into the HelmRelease (cni.confPath) and
  rolls the agents.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:43:48 +02:00
k.renner 8a3c66eb2e Merge pull request 'chore(talos): record #832 stage labels on k8s-1' (#914) from chore/cni-stage-k8s-1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#914
2026-09-23 09:31:07 +00:00
claude-botandClaude Opus 5.5 9928999da4 chore(talos): record #832 stage labels on k8s-1
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-1 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.1) and no restarts. All three nodes are converted.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:15:45 +02:00
k.renner b3def28f7c Merge pull request 'fix(whereabouts): let the reconciler respect cordons' (#913) from fix/whereabouts-reconciler-cordon into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#913
2026-09-23 09:04:29 +00:00
claude-botandClaude Opus 5.5 93dbcdb2fb fix(whereabouts): let the reconciler respect cordons
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 Deployment tolerated every NoSchedule taint (operator: Exists, no
key), including node.kubernetes.io/unschedulable. During the #832 node
reboots its ReplicaSet kept placing replacement pods on the cordoned,
shutting-down node; the kubelet rejected each with NodeShutdown, about
200 failed pods per reboot. Tolerate only the control-plane taint the
comment names.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:56:50 +02:00
k.renner 47bf16cfac Merge pull request 'chore(talos): record #832 stage labels on k8s-2' (#912) from chore/cni-stage-k8s-2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#912
2026-09-23 08:45:30 +00:00
claude-botandClaude Opus 5.5 4664de8f97 chore(talos): record #832 stage labels on k8s-2
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-2 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.2) and no restarts.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:44:17 +02:00
k.renner fb52e4be88 Merge pull request 'chore(talos): record #832 stage labels on k8s-3' (#911) from chore/cni-stage-k8s-3 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#911
2026-09-23 08:28:40 +00:00
claude-botandClaude Opus 5.5 f11a71c33f chore(talos): record #832 stage labels on k8s-3
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-3 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, Cilium writes multus/05-cilium.conflist, and after a reboot
with immediate uncordon the OSD came up with net1 (10.99.0.3) and no
restarts.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 10:25:53 +02:00
k.renner 29326eead3 Merge pull request 'feat(cni): per-node switch for Multus as sole CNI writer (stage A of #832)' (#910) from feat/cni-staged-sole-writer into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#910
2026-09-23 07:52:22 +00:00
claude-botandClaude Opus 5.5 eb09b1b2a3 feat(cni): per-node switch for Multus as sole CNI writer (stage A of #832)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing changes until a node is labelled:
- post-crds/cilium-nodeconfig.yaml: CiliumNodeConfig cni-uninstall
  (label cni.renner.uno/cilium-uninstall) arms the agent's preStop
  conflist cleanup; multus-sole-writer (label
  cni.renner.uno/multus-sole-writer) moves write-cni-conf-when-ready to
  /host/etc/cni/net.d/multus/05-cilium.conflist, a subdirectory libcni
  does not read.
- multus/multus-subdir.yaml: kube-multus-ds-subdir with
  multusAutoconfigDir /host/etc/cni/net.d/multus, scheduled only on
  labelled nodes; kube-multus-ds gets the inverse affinity.

The kube-multus-ds template change rolls Multus node by node once on
merge.

Refs #832

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 09:47:16 +02:00
k.renner dd3a1f1e90 Merge pull request 'feat(netbird-service-sync): run the controller in its own namespace (stage 2 of #876)' (#909) from feat/netbird-service-sync-phase2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#909
2026-09-23 06:55:25 +00:00
claude-botandClaude Opus 5.5 98a0462137 feat(netbird-service-sync): run the controller in its own namespace (stage 2 of #876)
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
- kubernetes/infrastructure/netbird-proxy-services -> netbird-service-sync:
  SA, ClusterRole(Binding) and Deployment renamed, in namespace
  netbird-service-sync, image gitea_admin/netbird-service-sync pinned to
  the master build digest, BAO_ROLE=netbird-service-sync.
- Old policy doc netbird-proxy-services.hcl removed; Traefik comment
  follows the name.

The infrastructure KS owns both old and new objects, so prune and apply
happen in one pass. OpenBao policy and role netbird-service-sync are
already written.

Closes #876

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:41:20 +02:00
k.renner 9aef2e0669 Merge pull request 'feat(netbird-service-sync): prepare rename and own namespace (stage 1 of #876)' (#908) from feat/netbird-service-sync-phase1 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#908
2026-09-23 06:35:14 +00:00
claude-botandClaude Opus 5.5 fd7597ddc2 chore(netbird-service-sync): drop stray __pycache__
ci/woodpecker/push/build-ci-images Pipeline was successful
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 (1M context) <noreply@anthropic.com>
2026-09-23 08:30:28 +02:00
claude-botandClaude Opus 5.5 10b0c1b853 feat(netbird-service-sync): prepare rename and own namespace (stage 1 of #876)
ci/woodpecker/push/build-ci-images Pipeline was canceled
Additive; the running controller is untouched:
- ci/images/netbird-proxy-services -> ci/images/netbird-service-sync,
  build step and image name renamed, so the merge builds
  gitea_admin/netbird-service-sync.
- Namespace netbird-service-sync with a baseline policy: probes in;
  out only to kube-dns, kube-apiserver, OpenBao :8200 and world :443
  (NetBird management API).
- openbao-ns admits netbird-service-sync on :8200, like wiki-index.
- policies/netbird-service-sync.hcl documents the new policy and role
  (applied by hand, as before).

Refs #876

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:30:15 +02:00
k.renner 674169cb08 Merge pull request 'chore(gitea): drop the apps-namespace grants for Gitea (stage 3 of #878)' (#907) from chore/gitea-namespace-phase3 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#907
2026-09-23 06:20:17 +00:00
claude-botandClaude Opus 5.5 0c6174b7a6 chore(gitea): drop the apps-namespace grants for Gitea (stage 3 of #878)
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 runs in the gitea namespace since #906. Remove what only served it
in apps:
- openbao rotator egress (flux-git, review-bot): apps/gitea target.
- ai-ns wiki-index ingress: apps/gitea source.
- apps default-deny egress to wiki-index :8080 (gitea's webhook; now in
  gitea-ns.yaml).
- Velero schedule comments.

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 08:09:30 +02:00
k.renner 76888c3771 Merge pull request 'feat(gitea): move Gitea into the gitea namespace (stage 2 of #878)' (#906) from feat/gitea-namespace-phase2 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#906
2026-09-23 06:05:33 +00:00
claude-botandClaude Opus 5.5 eb23d3ac6e feat(gitea): move Gitea into the gitea namespace (stage 2 of #878)
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
- kubernetes/apps/gitea: every object in namespace gitea; applied by a
  new gitea Flux Kustomization (clusters/homelab/gitea.yaml) instead of
  apps. gitea-allow-metrics-scrape dropped: the gitea-ns baseline
  already admits monitoring.
- Rotators (flux-git, review-bot) and the Prometheus gitea job use
  gitea.gitea.svc.
- bootstrap.sh waits for the gitea KS.

Data: data-gitea-0 is not copied. The PV (reclaimPolicy Retain) is
rebound to a PVC gitea/data-gitea-0 pre-created with volumeName, which
the new StatefulSet adopts. gitea-tls is pre-issued in the gitea
namespace so HTTPS has no certificate gap.

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:44:26 +02:00
k.renner c1e3f3fb98 Merge pull request 'feat(gitea): prepare the gitea namespace (stage 1 of #878)' (#905) from feat/gitea-namespace-phase1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#905
2026-09-23 05:35:18 +00:00
claude-botandClaude Opus 5.5 036399095b feat(gitea): prepare the gitea namespace (stage 1 of #878)
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
Additive only, nothing moves yet:
- Namespace gitea, declared in infrastructure like woodpecker.
- network-policies/gitea-ns.yaml: baseline (same-ns, monitoring, host,
  ICMP; egress to traefik, kube-dns, wiki-index, world) and
  gitea-allow-traefik for :3000 and :2222.
- openbao rotator egress (flux-git, review-bot) and wiki-index ingress
  also admit gitea in the gitea namespace; the apps entries stay until
  stage 3.
- Velero schedule gitea-daily (07:45) so data-gitea-0 stays backed up
  after the move; apps-daily comment updated (Woodpecker left apps).

Refs #878

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:30:36 +02:00
k.renner 0bac0051b2 Merge pull request 'chore(ci): bump ci-claude image to 2.1.280' (#904) from ci-image/claude-2.1.280 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#904
2026-09-23 05:16:03 +00:00
claude-bot 41a65b65a3 chore(ci): bump ci-claude image to 2.1.280
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-09-23 05:13:16 +00:00
k.renner a530f7c8e6 Merge pull request 'fix(ci-claude-refresh): bump divergent pins instead of aborting' (#903) from fix/ci-claude-refresh-mixed-pins into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#903
2026-09-23 05:12:40 +00:00
claude-botandClaude Opus 5.5 8fff4b4012 fix(ci-claude-refresh): bump divergent pins instead of aborting
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
decide required every consumer to pin the same ci-claude tag and aborted
otherwise (pipeline 10497: [2.1.250 2.1.278]), which needed a hand fix
before the latest version could roll out. Record all distinct tags, bump
whenever any is off latest, and rewrite every version-pinned tag to the
new one with a validated x.y.z version.

Merging this is itself a push touching the pipeline file, so the refresh
runs, builds the latest image, and opens the bump PR.

Refs #889

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 07:06:56 +02:00
k.renner 0ada3df260 Merge pull request 'fix(ci-claude-refresh): strip CR/LF from the bump token before use' (#901) from fix/ci-claude-bump-token-newline into master
ci/woodpecker/push/ci-claude-refresh Pipeline failed
Reviewed-on: k.renner/homelab#901
2026-09-23 05:03:19 +00:00
claude-botandClaude Opus 5.5 2144554cfc fix(ci-claude-refresh): strip CR/LF from the bump token before use
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 10186 failed in bump-pr with 'url contains a newline in its
username component': ci_claude_bump_bot_token carries a trailing newline,
so the push URL (and the PR API auth header) broke. Strip CR/LF once at
the top of the step and fail fast if the token ends up empty. This makes
the step independent of how the secret value was written.

Closes #889

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 06:59:15 +02:00
k.renner dba810b87b Merge pull request 'fix(paperless): raise the memory limit to 4Gi' (#900) from worktree-fix-paperless-memory-limit into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan 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
Reviewed-on: k.renner/homelab#900
2026-09-21 19:29:08 +00:00
claude-botandClaude Fable 5.1 f8aa65362b fix(paperless): cap OCR at 5 threads per worker
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
Unset, paperless derives cpu_count / TASK_WORKERS, i.e. 20 parallel
ocrmypdf workers on k8s-1. Memory during OCR scales with that count, so
the limit alone is not enough headroom for large scans.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:26:35 +02:00
claude-botandClaude Fable 5.1 0ce0391bb1 fix(paperless): raise the memory limit to 4Gi
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
An 18 MB scan from the ADS-1700W has kept the paperless container in an
OOM crash loop since 2026-09-21 10:21: ocrmypdf runs with 20 workers
(paperless default, cpu_count / TASK_WORKERS on a 20-core node) and blows
the 2Gi limit ~40s into OCR. The consume task is re-queued on every
restart, so the same file kills every attempt. 117 restarts so far, web
UI down as a side effect since worker and webserver share the container.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 21:24:36 +02:00
security-bot 4611ea3578 chore(image-automation): bump redis:8.10.2-alpine
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-21 18:52:58 +00:00
k.renner 63701fbebb Merge pull request 'ci(wiki-maintenance): verify the PR merged instead of trusting the merge status' (#895) from fix/wiki-maintenance-merge-verify into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#895
2026-09-21 08:49:10 +00:00
k.renner e8a047eb14 Merge pull request 'ci(pick-model): newest among comparable candidates, policy in code' (#894) from feat/pick-model-newest-among-comparable into master
Reviewed-on: k.renner/homelab#894
2026-09-21 08:48:58 +00:00
claude-botandClaude Fable 5.1 0279210595 ci(wiki-maintenance): verify the PR merged instead of trusting the merge status
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
Run 10254: POST .../merge with delete_branch_after_merge returned
HTTP 500 although PR #2 on k.renner/notes was merged, so the step
failed and left the branch behind. The merge call no longer asks Gitea
to delete the branch; the step reads the PR back and requires
merged=true, then deletes the branch as a separate best-effort call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:06:23 +02:00
claude-botandClaude Fable 5.1 25f09e9b52 ci(pick-model): newest among comparable candidates, policy in code
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First live pick after #893 chose claude-sonnet-4-5 at 0.23 over
claude-sonnet-4-6: same-family models split the probability for a
mid-weight task, and "prefer newer" is a policy, not a judgment. New
scripts/ci/choose-model.sh applies it to the distribution: among
candidates within CHOOSE_MODEL_MARGIN (0.7) of the best probability the
most recently released wins; a clear best stays; NONE yields nothing.
The picker logs the top probabilities. Four policy tests added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:59:40 +02:00
k.renner 82f2825d3f Merge pull request 'ci(pick-model): choose the model with one TypeSafe Choice, claude as fallback' (#893) from feat/pick-model-typesafe into master
ci/woodpecker/manual/wiki-maintenance Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#893
2026-09-21 07:56:11 +00:00
claude-botandClaude Fable 5.1 0fec080b4d ci(pick-model): choose the model with one TypeSafe Choice, claude as fallback
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The picker ran a full claude -p in plan mode per selection and parsed
JSON out of prose. With TYPESAFE_API_KEY set it now sends the task and
the catalog rows as state and asks one Choice over the model ids, each
with its catalog row as criterion plus NONE; the id is validated against
the pristine catalog as before. No key or a failed request falls through
to the claude picker unchanged.

Measured on the two-model fixture (Opus 5 / Haiku 4.5): issue-fix and
security-fix -> Opus 0.99, alert triage -> Opus 0.62, wiki audit ->
Haiku 0.94, update audit -> Haiku 0.46, distill -> Haiku 0.63; about a
second per pick. Dry-run test added to test-model-selection.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:42:39 +02:00
k.renner 7424c6aefd Merge pull request 'ci(wiki-maintenance): rebase onto main before pushing, retry three times' (#892) from fix/wiki-maintenance-rebase-push into master
ci/woodpecker/manual/wiki-maintenance Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#892
2026-09-21 06:41:40 +00:00
claude-botandClaude Fable 5.1 4e3fb88309 ci(wiki-maintenance): push a branch and merge it through Gitea
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
Instead of rebasing onto a moving main: the run's changes go to
wiki-maintenance/<date>-<run>, a PR is opened and merged via the API
(Do: merge, branch deleted afterwards). A merge conflict leaves the PR
open for a human and fails the run. Fields checked against the
instance's swagger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:40:47 +02:00
claude-botandClaude Fable 5.1 c8c5ce9015 ci(wiki-maintenance): rebase onto main before pushing, retry three times
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
Run 10241: distill and audit succeeded (5 sources, 8 new + 6 updated
notes), the push was rejected non-fast-forward because the workstation
pushed the vault while the agents ran. The push step now fetches,
rebases and retries; a real conflict still fails the run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:36:58 +02:00
k.renner 293f51b914 Merge pull request 'ci(wiki-maintenance): strip CR/LF from secrets before use' (#891) from fix/wiki-maintenance-strip-secrets into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/manual/wiki-maintenance Pipeline failed
Reviewed-on: k.renner/homelab#891
2026-09-21 06:22:44 +00:00
claude-botandClaude Fable 5.1 70287db150 ci(wiki-maintenance): allow hand-started runs via the manual event
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 deployment-event run (10239) errored before its first step: the
typesafe_api_key secret is scoped to cron and manual only. Manual
pipelines (woodpecker pipeline create) are the way to start this by
hand; the deployment trigger is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:20:36 +02:00
claude-botandClaude Fable 5.1 efedba1ff0 ci(wiki-maintenance): strip CR/LF from secrets before use
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
First cron run (10220) failed in checkout-notes with "could not read
Username": the wiki_bot_token secret had been set from a file with a
trailing newline, which git carried into the extraheader. The secret
is rewritten without it; the pipeline now strips CR/LF from the token
and the index credential regardless of how they were entered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:19:12 +02:00
security-bot f35a2d5985 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.2.1
ci/woodpecker/cron/wiki-maintenance Pipeline failed
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/update-audit 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-20 21:51:31 +00:00
k.renner 19b641a2b6 Merge pull request 'ci(wiki-maintenance): run on ci-claude 2.1.278' (#890) from fix/wiki-maintenance-image-2.1.278 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#890
2026-09-20 20:46:19 +00:00
claude-botandClaude Fable 5.1 e422e6a13d ci(wiki-maintenance): run on ci-claude 2.1.278
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 refresh triggered by #888 built 2.1.278 (the first ci-claude with
python3) rather than rebuilding 2.1.250, and its bump-pr step failed on
a newline in the push token secret (#889), so the consumer bump never
came. This pipeline needs python for wiki_ask.py, so it moves ahead of
the general bump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:45:02 +02:00
k.renner f4d590c40d Merge pull request 'feat(ci): nightly wiki maintenance for the Notes vault as a Woodpecker cron' (#888) from feat/wiki-maintenance-ci into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#888
2026-09-20 20:41:58 +00:00
claude-botandClaude Fable 5.1 791cb6e524 feat(ci): nightly wiki maintenance for the Notes vault as a Woodpecker cron
ci/woodpecker/push/build-ci-images Pipeline was successful
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
Replaces the workstation's wiki-maintenance.timer. The pipeline clones
k.renner/notes, runs wiki-distill over raw/ and wiki-audit over wiki/
as two separate `claude --agent` runs (no background tasks in print
mode, which killed the workstation job at the 600 s ceiling), then
commits and pushes as Claude Bot; the push webhook refreshes wiki-index.

The agent definitions now live in the vault (.claude/agents/), so CI
and workstation share them. The agents and their retrieval scripts
address the vault as ~/Notes; each agent step links $HOME/Notes to the
checkout. ci-claude gains python3 and py3-yaml for wiki_ask.py.

Secrets on the repo: claude_code_oauth_token (existing), typesafe_api_key,
wiki_bot_token (claude-bot, write:repository), wiki_index_basic_auth.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 22:37:59 +02:00
k.renner 3749df798e Merge pull request 'ci(wiki-index): per-commit image tags, deployment pinned to one' (#886) from fix/wiki-index-commit-tags into master
ci/woodpecker/push/build-ci-images 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
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#886
2026-09-20 09:00:25 +00:00
claude-botandClaude Fable 5.1 50b4cbeb2b fix(wiki-index): pin the deployment to the per-commit tag
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
wiki-index:2026-09-20-4b529cd1 is the build of the previous commit,
i.e. master's code with the paging fix and /ready. Manifest-only
commit, so it does not trigger another build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:55:57 +02:00
claude-botandClaude Fable 5.1 4b529cd15c ci(wiki-index): also tag the image per commit
ci/woodpecker/push/build-ci-images Pipeline was successful
Three builds on 2026-09-20 (two branch pushes, the merge) rewrote
wiki-index:2026-09-20 in an order the digest pin could not follow: the
pinned digest ran the paging-only build without /ready and crash-looped
on its startup probe. A <date>-<sha8> tag is immutable per commit and
lets the deployment pin by tag without a registry lookup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:55:38 +02:00
k.renner 53a9348254 Merge pull request 'fix(wiki-index): pin the image to the #882 build' (#885) from fix/wiki-index-pin-digest-2 into master
Reviewed-on: k.renner/homelab#885
2026-09-20 08:49:45 +00:00
claude-botandClaude Fable 5.1 ce06d4e7da fix(wiki-index): pin the image to the #882 build
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
Same dated tag again; the pod holds the #879 build. Pinning the new
digest (paging fix, /ready readiness) rolls it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:49:13 +02:00
k.renner e716632c5c Merge pull request 'fix(wiki-index): stop tree paging on the short last page' (#882) from fix/wiki-index-tree-paging into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#882
2026-09-20 08:46:35 +00:00
claude-botandClaude Fable 5.1 9736656fed fix(wiki-index): liveness on /health, readiness on /ready
ci/woodpecker/push/build-ci-images Pipeline was successful
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
/health answered 503 until an index existed and fed startup and
liveness, so a live process without an index counted as dead and was
restarted every 15 min, and a later failed sync would have pulled a pod
with a good index out of the Service. /health is now always 200
(process serves); /ready is 200 once an index exists and stays so
across failed syncs, which keep the last good index. Probes follow:
startup 60 s on /health, readiness on /ready, liveness on /health.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:45:50 +02:00
claude-botandClaude Fable 5.1 d4e5029ebd fix(wiki-index): stop tree paging on the short last page
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The notes repo tree has 3192 entries. Gitea reports truncated=true on
every page including the last one (page 4, 192 entries) and answers
"tree": null one page further, which the sync hit:
"sync failed: 'NoneType' object is not iterable". Paging now stops on a
short page or when total_count is reached, and a null tree is empty.
Unit test with the observed page shapes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:44:10 +02:00
k.renner 0fb5ff3fb6 Merge pull request 'fix(wiki-index): pin the image to the rebuilt digest' (#881) from fix/wiki-index-pin-digest into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#881
2026-09-20 08:40:41 +00:00
claude-botandClaude Fable 5.1 58e4c25729 fix(wiki-index): pin the image to the rebuilt digest
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
#879 rebuilt wiki-index under the same dated tag 2026-09-20; the node
still holds the first build (sha256:085438d8) and IfNotPresent would
not re-pull. Pinning tag@digest rolls the exact new image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:38:45 +02:00
k.renner a3ca6c6065 Merge pull request 'fix(wiki-index): strip OpenBao values, retry a failed sync after 60 s' (#879) from fix/wiki-index-strip-credentials into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#879
2026-09-20 08:36:03 +00:00
claude-botandClaude Fable 5.1 816f161591 fix(wiki-index): strip OpenBao values, retry a failed sync after 60 s
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`bao kv put key=@file` stores the file's trailing newline; the first
sync after #875 failed with "Invalid header value b'token ...\n'" and a
webhook signed with the newline-free secret would never have verified.
Credentials are stripped on read. A failed sync now retries after 60 s
instead of waiting the full RESYNC_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:29:43 +02:00
k.renner 5f86bdde64 Merge pull request 'fix(netpol): let gitea deliver the notes push webhook to wiki-index' (#877) from fix/gitea-webhook-egress-wiki-index into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#877
2026-09-20 08:24:40 +00:00
claude-botandClaude Fable 5.1 814c5b88fa fix(netpol): let gitea deliver the notes push webhook to wiki-index
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The apps namespace is egress default-deny; gitea's webhook to
wiki-index.ai.svc.cluster.local:8080 was dropped (a test delivery
produced no request at the service). Namespace-wide egress rule scoped
to the wiki-index pod on 8080, the mosquitto pattern; wiki-index's own
ingress policy already admits only the gitea pod from apps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:21:18 +02:00
k.renner 0420be0a21 Merge pull request 'fix(openbao): admit wiki-index to OpenBao on 8200' (#875) from fix/wiki-index-openbao-ingress into master
Reviewed-on: k.renner/homelab#875
2026-09-20 08:18:02 +00:00
claude-botandClaude Fable 5.1 7da72b34cf fix(openbao): admit wiki-index to OpenBao on 8200
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 openbao namespace policy admits only its own pods, Traefik and
External Secrets. wiki-index (ns ai) logs in through the Kubernetes auth
role on every sync and timed out on the first attempt:
"POST .../v1/auth/kubernetes/login: timed out". Pod-scoped ingress rule
for it, mirroring the external-secrets entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:14:47 +02:00
k.renner c36c290b13 Merge pull request 'feat(ci): wiki-index image — embedding index over the Notes repo' (#873) from feat/wiki-index-image into master
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#873
2026-09-20 08:11:32 +00:00
k.renner 8fb7430fb7 Merge pull request 'feat(ai): deploy wiki-index, the embedding index service for the Notes repo' (#874) from feat/wiki-index-deploy into master
Reviewed-on: k.renner/homelab#874
2026-09-20 08:11:21 +00:00
claude-botandClaude Fable 5.1 d82b96a751 fix(wiki-index): notes repository branch is main
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 10:09:43 +02:00
claude-botandClaude Fable 5.1 463cfa7bde feat(ai): deploy wiki-index, the embedding index service for the Notes repo
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
Deployment + Service + PVC + ServiceAccount in namespace ai, LAN-only
Ingress wiki-index.renner.uno behind Traefik BasicAuth (ExternalSecret
from OpenBao, same shape as wiki-embed), Flux Kustomization depending on
llama-server (wiki-embed is the embedding backend), pod-scoped
CiliumNetworkPolicy admitting Traefik and Gitea (push webhook) on 8080
and allowing egress to OpenBao 8200, and the OpenBao policy for the
service's Kubernetes auth role.

Image: gitea.renner.uno/gitea_admin/wiki-index:2026-09-20, built by the
step added in the previous PR; the tag is the build date.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 08:47:26 +02:00
claude-botandClaude Fable 5.1 48ba19064a feat(ci): wiki-index image — embedding index over the Notes repo
ci/woodpecker/push/build-ci-images Pipeline failed
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
Runtime image for a wiki-index service in namespace ai (manifests follow
once the image exists): standard-library Python that mirrors the notes
repository's wiki/ from Gitea into a bge-m3 index and serves POST /rank
to ~/Notes/bin/wiki_rank.py.

Sync fetches the branch head and tree via the Gitea API, embeds only notes
whose blob sha changed (through the in-cluster wiki-embed), drops notes
that left the tree, and persists the index on a PVC. It runs at start, on
every push webhook (HMAC-SHA256 verified) and every RESYNC_SECONDS. Gitea
token and webhook secret come from OpenBao through the Kubernetes auth
role on every use. Five unit tests cover parsing, incremental sync,
persistence, ranking and error handling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 08:42:03 +02:00
k.renner 761bf9ba1e Merge pull request 'fix(eso): allow namespace ai on the openbao ClusterSecretStore' (#872) from fix/eso-allow-ai into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#872
2026-09-20 05:48:21 +00:00
claude-botandClaude Fable 5.1 7b446194b3 fix(eso): allow namespace ai on the openbao ClusterSecretStore
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 wiki-embed-basic-auth ExternalSecret from #871 never synced:
"using cluster store openbao is not allowed from namespace ai: denied by
spec.condition". The store's namespace allowlist predates the ai
namespace. Adding it, sorted, as the manifest's own maintenance note
prescribes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 07:47:43 +02:00
k.renner 9eaafeb92f Merge pull request 'feat(ai): dedicated wiki-embed endpoint for the Notes ranker' (#871) from feat/wiki-embed into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#871
2026-09-20 05:38:26 +00:00
claude-botandClaude Fable 5.1 67d506e1ac feat(ai): dedicated wiki-embed endpoint for the Notes ranker
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Second bge-m3 llama.cpp Deployment in ns ai so wiki queries never queue
behind Paperless on llama-embed. Shares the llama-server-models PVC, so
the weights are not downloaded again.

Exposed LAN-only as wiki-embed.renner.uno (no dns.renner.uno/public
label) for bin/wiki_rank.py on the workstation and the nightly wiki jobs.
The client is a script, and the Authentik gate is browser-only, so the
Ingress uses Traefik BasicAuth backed by an ExternalSecret, the same
shape as loki.renner.uno. A pod-scoped CiliumNetworkPolicy admits Traefik
to wiki-embed only; llama-server stays reachable solely via its gate.

Measured on 40 past wiki-synth queries: embeddings picking 150 candidates
plus one TypeSafe Choice puts a cited note in the top 5 for 95% of
queries at a sixth of the cost of ranking all 1700 notes with TypeSafe.

Needs before first use: OpenBao key apps/ai/wiki-embed-basic-auth with
field `users` (htpasswd line), and the same credential as user:password
in ~/.config/wiki-embed/basic-auth on the workstation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 23:23:30 +02:00
security-bot 932d07edbe chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.2.0
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
Updates managed by image-automation-controller.
2026-09-19 02:51:13 +00:00
security-bot 1a1205110d chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.3
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-18 12:53:31 +00:00
security-bot aed827ae5b chore(image-automation): bump ghcr.io/music-assistant/server:2.10.4
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-18 09:51:06 +00:00
k.renner 3abcec5af7 Merge pull request 'feat(heating-can): read the One Base per-room telemetry over CAN' (#867) from feat/onebase-room-telemetry into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#867
2026-09-17 11:17:30 +00:00
claude-botandClaude Opus 5 b14bf0cc5e feat(heating-can): read the One Base per-room telemetry over CAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Einzelraumsteuerung (8 radiator valves, 9 climate sensors, 9 underfloor
actuators, 3 floor thermostats) runs on Viessmann's One Base ZigBee system.
Its room and device registries and its live per-device values sit on ECU 0x6c3
(BACKENDGATEWAY) and are readable in plaintext over the E3 bus -- room
temperature, the active target temperature, humidity, valve opening, demand,
battery and RSSI. That replaces the ViCare cloud room sensors with a local
source.

0x6c3 had never been enumerated: only 0x680, 0x6a1 and 0x68c ever had a full
scan. A full read of 0x6c3 returns 155 decodable datapoints plus 35 that exist
but fail the stock codec.

Those 35 are the reason for the overlay. open3e declares
ZigBee<N>DeviceCurrentValues as 57 bytes; a PAIRED slot answers with 68 -- the
known 57 plus the parent device serial and three trailing bytes. udsoncan reads
only the declared length and then treats the next two bytes, the first half of
that serial, as a foreign data identifier, so every paired slot fails with
"Server returned values for data identifier 0x.... that was not requested". The
bogus identifier is stable per device and differs between devices because the
serial does, which is why this previously read as an encrypted channel. It is
not encrypted.

The overlay re-declares the 29 paired slots at 68 bytes and inherits upstream's
sub-type list, so the per-device-type switch -- including
DeviceTemperatureSetpoint -- comes from upstream rather than being copied.
Empty slots answer with 57, decode against the stock codec and are not polled.

Only CurrentValues are polled. The registries are static configuration and are
read on demand.

Verified against payloads captured from 0x6c3 on 2026-09-17, decoded with
open3e 0.7.5 (the commit pinned in the heating-can image): all 29 slots are 68
bytes, and a radiator valve yields ActualTemperature 19.3 °C, ValveOpening 0 %,
DeviceTemperatureSetpoint 8.0 °C, battery 38 %, RSSI -69 dBm. The setpoint
field was confirmed by changing one room in the ViCare app three times and
re-reading: 8.0 -> 15.0 -> 13.0.

Read-only. No write path to the per-room setpoint is known; writes to the
CurrentValues DID were attempted and had no effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:13:49 +02:00
k.renner 58444c9862 Merge pull request 'fix(external-dns): let Prometheus reach the metrics port' (#866) from fix/external-dns-scrape-policy into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#866
2026-09-17 08:35:13 +00:00
claude-botandClaude Opus 5 5f3aecb381 fix(external-dns): let Prometheus reach the metrics 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 ServiceMonitor added in #865 discovers both external-dns targets and
then times out on them: the apps namespace is default-deny-ingress, so the
scrape never arrives.

  lastError: Get "http://10.244.2.218:7979/metrics": context deadline exceeded
  health: down

Same shape as gitea-allow-metrics-scrape, and one policy covers both
releases — the chart gives them the same app.kubernetes.io/name and they
share the namespace, even though external-dns-unifi is declared under
infrastructure/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:29:53 +02:00
k.renner 4ad47b3925 Merge pull request 'feat(monitoring): watch DNS publication — external-dns metrics plus resolution probes' (#865) from feat/dns-monitoring into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#865
2026-09-17 08:26:04 +00:00
claude-botandClaude Opus 5 2131c7b5f5 feat(monitoring): probe that the LAN names still resolve
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
external-dns metrics say what the publisher intends. This says what a client
actually gets, and catches causes the publisher cannot see — a record removed
on the UDM directly, for one.

Two findings shaped it.

The probe asks the UDM directly rather than the cluster resolver. k8s-gateway
caches these names with `serve_stale 1h immediate`, so a deleted record would
keep answering from cache for an hour before the probe noticed.

And rcode alone is not a usable signal here. Measured against 10.2.40.1:

  does-not-exist-xyz.renner.uno            -> NOERROR, CNAME proxy.renner.uno
  does-not-exist-xyz.paperless.renner.uno  -> no answer

The public *.renner.uno wildcard matches one label deep, which is exactly
where ha, mqtt, ldap and syslog live: delete their records and the UDM still
answers NOERROR, pointing at the WAN proxy. A probe checking only the rcode
would have stayed green through the outage for four of the six names. So the
modules require an A record in the answer and reject a CNAME, which separates
a real record from the wildcard without encoding any address.

One module per name because the dns prober reads query_name from the module
and takes the resolver as its target; both families are probed per name and
the alert wants both to fail, so the UDM's bursty connection refusals do not
read as a missing record. That is also why the window is 30m rather than the
10m the HTTP probes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:20:04 +02:00
claude-botandClaude Opus 5 d35c06d21d feat(monitoring): scrape external-dns and alert when it stops publishing
Neither external-dns release was scraped: both expose /metrics on :7979 and
no ServiceMonitor existed, so external_dns_* series were absent from
Prometheus entirely. The process that publishes every LAN name in the
cluster had no instrumentation at all.

That is how 2026-09-12 went unnoticed for five days. external-dns v0.22.0
changed the default annotation prefix, the service source went empty, and
policy=sync deleted six records. source_endpoints_total fell 48 -> 36 and
controller_verified_records 24 -> 18 per family; nothing was watching either.

ExternalDNSSourceEndpointsDropped is the rule for that shape: a sustained
drop below 90% of the 24h peak. The threshold is chosen against the observed
inventory — one retired Ingress is 2 endpoints of ~48 and stays quiet, a
whole class of names does not. A deliberate mass removal fires it once and
clears itself after 24h, which is the right trade for a signal that
otherwise only speaks when records are already gone.

The rest of the group covers the ways the drop rule itself can go blind:
source and registry errors, the instance being down, and an absent() dead
man mirroring BlackboxProbesAbsent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:15:18 +02:00
k.renner 1bbff9fca4 Merge pull request 'fix(dns): migrate external-dns hostname annotations off the alpha prefix' (#864) from fix/external-dns-annotation-prefix into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#864
2026-09-17 08:02:34 +00:00
claude-botandClaude Opus 5 9a521568c0 fix(dns): migrate external-dns hostname annotations off the alpha prefix
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
external-dns v0.22.0 changed the default --annotation-prefix from
external-dns.alpha.kubernetes.io/ to external-dns.kubernetes.io/ with no
fallback, and its release notes lead with the consequence: "This change can
delete all your DNS records."

The chart floated onto it at 2026-09-11 23:47:56 (appVersion 0.21.0 ->
0.22.0). Two hours later, at 2026-09-12 01:47:59, the unifi provider logged
`client deleted record` for every name that depended on the annotation:

  ha.renner.uno       mqtt.renner.uno     ldap.renner.uno
  syslog.renner.uno   ftp.paperless.renner.uno
  syncthing.paperless.renner.uno

A, AAAA and the TXT ownership records, and no `client created record` since.
Every one of those is a LoadBalancer Service, whose hostname external-dns
can only learn from the annotation — so the source went silent and
--policy=sync removed the records. Ingress-sourced names take their host
from spec.rules[].host and were untouched, which is exactly the split
observed: source_endpoints_total is 36 with source_errors_total 0.

These six annotations were the only alpha-prefixed external-dns annotations
in the repo, so moving them to the current prefix is the whole fix. Pinning
the prefix back with --annotation-prefix would keep us on a deprecated
default that upstream intends to drop.

Scanning has been broken since: the Brother resolves ftp.paperless.renner.uno
rather than using the address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:00:30 +02:00
security-bot 3b3ed4fbc4 chore(image-automation): bump gitea.renner.uno/k.renner/vsftpd:build.1789630783
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-17 07:43:07 +00:00
k.renner c40466a3e6 Merge pull request 'fix(paperless): stop the ftp-scanner probe from crashing vsftpd' (#862) from fix/ftp-scanner-probe into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#862
2026-09-17 07:39:25 +00:00
k.renner ff0b3434c1 Merge pull request 'fix(libation): persist refreshed Audible tokens back to the PVC' (#861) from fix/libation-accounts-persistence into master
Reviewed-on: k.renner/homelab#861
2026-09-17 07:39:07 +00:00
claude-botandClaude Opus 5 2cfebc2f8b refactor(paperless): drop the duplicated PASV address from ftp-scanner
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 LB address was declared twice: on the Service as io.cilium/lb-ipam-ips,
and again as a literal PASV_ADDRESS in the ConfigMap generator.

The copy was not doing anything. vsftpd listens on a single IPv6 socket, so
an IPv4 client arrives as ::ffff:a.b.c.d and vsftpd answers PASV with
0.0.0.0 regardless of the configured address — the only such session in 30
days of logs got `227 Entering Passive Mode (0,0,0,0,82,109)` while
PASV_ADDRESS=10.2.99.82 was set. IPv6 clients negotiate EPSV, which carries
a port and no address at all; that is what the scanner uses.

Removing the literal falls back to the image default of 0.0.0.0, which is
the reply clients already receive, so behaviour is unchanged and the address
now exists in exactly one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:56:51 +02:00
claude-botandClaude Opus 5 3481e43032 fix(paperless): stop the ftp-scanner probe from crashing vsftpd
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
vsftpd 3.0.5 segfaults its standalone listener on roughly 0.3% of the
connections it accepts: the parent closes the accepted socket before it
registers the forked child's pid, so a child that exits inside that window
makes the SIGCHLD path look up an unregistered pid, get NULL, and
dereference it in hash_ip(). PID 1 is vsftpd, so the segfault takes the
container with it.

The tcpSocket probes added in 469cdbc2 opened ~8600 connections a day
against that, which turned a latent crash into a restart every ~25 minutes
and left the scanner hitting CrashLoopBackOff windows. Measured on the live
pod: 52 restarts in 21h, one node-side `vsftpd[...]: segfault at 0` per
restart, starting the hour the probes rolled out.

Assert the listening socket from inside the container instead. The probe
still catches the failure it was added for — vsftpd not serving port 21 —
without being a client of the process it watches.

vsftpd 3.0.5 is upstream's last release (2021) and Alpine ships it
unpatched in 3.21 (3.0.5-r2) and edge (3.0.5-r3), so there is no version
bump that fixes this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:52:14 +02:00
security-bot 98475a47e2 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.36.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-16 22:50:50 +00:00
security-bot 6f20955e3b chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.74.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-16 19:51:35 +00:00
claude-botandClaude Opus 5 92ac021931 feat(libation): seed the token master key onto the PVC
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
TokenStorageMethod is Encrypted and the container has no OS secret store,
so LibationCli falls back to minting a portable master key at
/config-internal/libation-master.key -- a path that is not a mounted
volume. The key is therefore new on every run and discarded at exit,
which is why the entrypoint logs LAST-RESORT on each pass.

The entrypoint looks for the key at ${LIBATION_CONFIG_DIR}/
libation-master.key, i.e. on the PVC, and copies it into place when
found. The key is now stored in OpenBao alongside the other bootstrap
files, so seed it like them: without it a rebuilt PVC would hold tokens
encrypted under a key that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:47:24 +02:00
claude-botandClaude Opus 5 5bbca6a162 fix(libation): persist refreshed Audible tokens back to the PVC
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The image entrypoint stages config into /config-internal, which is not a
mounted volume, and it does so by copy:

    init_config_file() {
      ...
      cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/

Only LibationContext.db is symlinked to the PVC. AccountsSettings.json is
therefore read from the PVC at startup and written back nowhere, so every
token refresh Libation performs is discarded when the container exits. On
the live PVC the file still carried its 2026-05-14 seed timestamp, four
months after deployment, while the DB beside it was current.

Pass args instead of letting the entrypoint run its own loop. The
entrypoint completes its setup and then execs what it is given, so the
staged environment is intact; an EXIT trap copies the refreshed file back
to the PVC whether the run succeeds or fails.

This was found while recovering from Audible refusing content licenses
for this account. Re-registering the account fixed the refusals; without
this change the fresh registration would have been discarded on the next
run exactly like every refresh before it.

Also corrects the init container comment, which claimed the sentinel
preserved token refreshes. It preserves the DB; it never preserved the
tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:26:49 +02:00
k.renner edb07a344e Merge pull request 'fix(libation): pin image to 14.2.1 by digest' (#860) from fix/libation-pin-14.2.1 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#860
2026-09-16 15:51:49 +00:00
claude-botandClaude Opus 5 260f548e67 fix(libation): pin image to 14.2.1 by digest
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 CronJob ran `rmcrackan/libation:latest` with imagePullPolicy Always
but was stuck on 13.7.11 (2026-08-24). Spegel answers containerd's tag
resolution from its peer cache -- a 200 from the mirror is a hit, so
upstream is never consulted and the tag never advances. Verified: the
pod's imageID was sha256:946177d3 (= tag 13.7.11) while Docker Hub's
:latest pointed at sha256:6f9d8ba1 (= 14.2.1), and querying a Spegel
pod's registry port directly returned the stale digest for that tag.

Audible changed device registration in early September; upstream shipped
an experimental fix in 14.1 and the real fix in 14.2, so the stale image
predates both. Since 2026-09-15 three newly purchased titles fail with
"Content license denied" and the library has not grown.

A digest reference is immune to the mirror's tag cache. Note that the
upstream maintainer's remedy also requires removing and re-adding the
Audible account with an interactive login, which this change does not
and cannot perform.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:30:37 +02:00
k.renner ef6bc24491 Merge pull request 'feat(ceph): serve reads from the local OSD (#576)' (#859) from feat/ceph-read-affinity into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#859
2026-09-16 12:25:51 +00:00
claude-botandClaude Opus 5 779f557624 feat(ceph): serve reads from the local OSD (#576)
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
Measuring VLAN 40 for the traffic-class split turned up a share of the
management segment that is not management: the node's Ceph client
talking to OSDs on the other two nodes, sourced from the node address
on mgmt0.

Ceph sends every client read to the acting primary of the PG, which
with three OSDs is remote two times out of three. Read affinity has the
client read from the replica in its own CRUSH location instead.

The preconditions hold here. replicapool and cephfs/data0 are size 3
over failureDomain host across three nodes, so every object has a copy
on every node. The OSDs are created under zone buckets
(root=default host=k8s-N zone=<name>) and the nodes carry
topology.kubernetes.io/zone, which is in Rook's default
crushLocationLabels. Ceph is v20.2.4 and the kernel 6.18, against a
documented minimum of 5.8.

Writes are unaffected: they always go to the primary, and the
replication behind them already rides the VLAN 49 cluster network.

cephfs-ci is size 2, so a local replica is not guaranteed there and the
client falls back to the primary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:10:04 +02:00
k.renner 1fbefb3c39 Merge pull request 'fix(bird): put the node's own pod CIDR in the table, not a blackhole (#576)' (#858) from fix/bird-local-cidr 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
Reviewed-on: k.renner/homelab#858
2026-09-16 11:27:03 +00:00
claude-botandClaude Opus 5 f97aa077f0 fix(bird): put the node's own pod CIDR in the table, not a blackhole (#576)
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
Rolling out #857 took cross-node pod traffic down, and cluster DNS with it, until it was reverted. The table was incomplete: the node's own pod CIDR was originated as a blackhole so it could be advertised without being installed, and then filtered out of the kernel export.

The rule matches the SOURCE, and a reply from a pod on another node is forwarded traffic with a pod source — so it matches too. Its destination is a pod on this node, the table had no route for that, and it took the default and left for the UDM. The comment claiming such traffic would fall through to main was wrong: nothing falls through once a default matches.

Originating it via cilium_host instead makes it both advertisable and correct in the table, and the export filter that existed only to hide the blackhole goes with it.

Diagnosed by narrowing the rule to a single pod address, where the same path worked because the return packet no longer matched: 404 with src <pod>/32, 000 with src 10.244.0.0/16, 404 again once 10.244.0.0/24 dev cilium_host was added to table 47 by hand. bird -p parses the corrected config inside the image, exit 0.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:25:25 +02:00
k.renner 8aa662e997 Merge pull request 'feat: one pod routing table, decided by longest prefix (#576)' (#857) from feat/single-pod-table into master
Reviewed-on: k.renner/homelab#857
2026-09-16 11:11:53 +00:00
claude-botandClaude Opus 5 45012470c1 feat: one pod routing table, decided by longest prefix (#576)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline is running
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The rule stack grew a carve-out layer because table 47 held nothing but a default route: it matched everything, so every cluster-internal destination had to be excluded by a rule naming its prefix. Those exclusions were wrong twice over — too broad, since fd00::/8 and 10.0.0.0/8 also caught every LAN client and sent its reply back over mgmt0, and unnecessary, since the networks in question are directly attached and a connected route already beats a default.

So the table gets the connected routes and decides on its own. bird's direct protocol generates them from the interfaces rather than from a list, alongside the peers' pod CIDRs it already learns and the egress default it now originates. One rule per family remains, on the pod source prefix; 980/981, 990/991 and table 48 are gone.

bird owns the table outright, including the default. That is what keeps the failure benign: if the daemon stops the table empties, the rule finds nothing and falls through to main, so pod traffic returns to VLAN 40 unseparated rather than misrouted. A default declared in the Talos config would have outlived the specific routes and sent pod traffic to the UDM.

The ingress gateway moves from a route in each node patch to one node annotation in the site patch, which is now the only place it is stated.

Verified: bird -p parses the rendered config inside the image, exit 0; the templates render GW6 fd02:47::1 and GW4 10.2.47.1 from the live k8s-1 object; kubectl kustomize builds.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:09:13 +02:00
security-bot 688f84dc29 chore(image-automation): bump everythingsmarthome/everything-presence-mmwave-configurator:2.2.5
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-16 09:51:31 +00:00
k.renner 6335954d6c Merge pull request 'docs: describe the traffic-class split as built (#576)' (#856) from docs/traffic-classes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#856
2026-09-16 09:20:50 +00:00
claude-botandClaude Opus 5 f0701370db docs: describe the traffic-class split as built (#576)
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
Replaces the proposal, which was wrong before it was written down. It presented A, B and C as the option space and concluded pod-to-pod was blocked; policy routing was available the whole time and is what moved pod egress and then pod-to-pod. The file recorded a blind spot and gave it the authority of a checked-in document.

Three of four classes are separated now, so this describes the built design rather than proposing one, and the PROPOSAL- name goes with it: which link each class uses, the rule order and why 980/981 match source and destination, where each table comes from, how the bird mesh renders itself from the API without repeating an address, and why the node's own CIDR is originated rather than read back from the kernel.

Management is the remaining class. Its levers are listed, along with the two approaches the owner rejected — with the caveat that the same reasoning which produced that list also called pod-to-pod impossible, so it is a record of decisions and not an analysis to trust.

Failure behaviour is stated because it is what makes the design safe to roll one node at a time: an empty table 48 falls through to main and pod traffic returns to VLAN 40, unseparated rather than broken.

bootstrap.sh's reference follows the rename; its point was always that the QEMU/Hetzner sites use two segments, not the homelab's split.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:18:57 +02:00
k.renner 9131f73d6c Merge pull request 'feat(talos): send pod-to-pod over pod0 via table 48 (#576)' (#855) from feat/podpod-rules into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#855
2026-09-16 09:05:22 +00:00
claude-botandClaude Opus 5 7e19ed1b59 feat(talos): send pod-to-pod over pod0 via table 48 (#576)
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 last step of the traffic-class split. The bird mesh already fills table 48 with one route per peer pod CIDR, next hop the peer's pod0 address; these two rules are what make the kernel look there.

Source AND destination are matched. Source alone would also catch the node's own traffic to a pod, which would then leave with a pod0 source — an address CiliumNode does not list, so Cilium would classify it as world rather than host and policy would drop it. Destination alone would catch traffic from anywhere.

Priority 980/981 sits ahead of the 990/991 pair that sends the cluster and local networks to main, so pod-to-pod wins for that traffic and everything else is unchanged. Traffic to a pod on the same node falls through: table 48 holds only the peers' CIDRs, the lookup misses, and 990/991 send it to main and cilium_host.

Verified on k8s-1 with --mode=try, 200 requests per family from a pod on k8s-1 to a pod on k8s-2: pod0 gained 6957 transmit packets, matching 400 requests, while pod0 receive moved by 45 — the peer is unpatched and still answers over mgmt0, the same asymmetry the earlier measurement showed. Both rules were installed as expected, one per family.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:04:05 +02:00
k.renner 41556cbb16 Merge pull request 'fix(bird): originate the node's own pod CIDR instead of learning it' (#854) from fix/bird-static into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#854
2026-09-16 09:00:04 +00:00
claude-botandClaude Opus 5 377270375f fix(bird): originate the node's own pod CIDR instead of learning it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
IPv6 populated table 48 on all three nodes; IPv4 was empty. The daemon log gives the reason: KRT: Received route 10.244.0.0/24 with strange next-hop 10.244.0.195. Cilium's IPv4 pod route points at a next hop inside the prefix it describes, which BIRD's kernel protocol refuses. The IPv6 route is a device route with no next hop, so it passed — the asymmetry, not a filter, is why one family worked.

Reading the route back made BIRD's view depend on the shape Cilium happens to install. The prefix is already in the Node object, so a static protocol originates it directly: blackhole, because the route exists only to be advertised, and the sole kernel protocol exporting from these tables takes BGP routes.

That removes the kernel-learning tables, the pipes and the own-CIDR filters entirely — with them the dependence on learn all, on ifname, and on the maximum-length exclusion.

Verified: the templates render OWN_POD4 10.244.0.0/24 and OWN_POD6 fd10:244::/64 from the live k8s-1 object, and bird -p parses the result inside the image with exit 0.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:57:28 +02:00
k.renner df9019d90e Merge pull request 'fix(bird): reference the image by digest, the dated tag resolves stale' (#853) from fix/bird-digest into master
Reviewed-on: k.renner/homelab#853
2026-09-16 08:54:37 +00:00
claude-botandClaude Opus 5 c1ccf228b7 fix(bird): reference the image by digest, the dated tag resolves stale
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 DaemonSet came up with Init:Error, kubectl: not found. The build had pushed sha256:3bc85006 for bird:2026-09-16, but every pod resolved that tag to sha256:75e19214 — the first build of the same day, from before kubectl was added.

imagePullPolicy: Always does not help: it re-pulls, but the tag itself resolves to the stale digest. Deleting the pods changed nothing; three fresh pods on three nodes all resolved to the old digest. A one-off pod pulling the digest directly ran kubectl 1.33.1 and BIRD 2.17.2, so the registry holds the right image and only tag resolution is stale.

The dated tag cannot carry a same-day rebuild, and this image was rebuilt three times today. Referencing the digest makes the deployment deterministic. The cost is that the reference has to be updated whenever the image is rebuilt; the alternative, tagging by commit, does not work for a manifest living in the same commit.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:53:55 +02:00
k.renner 1cd26b74ae Merge pull request 'feat(bird): iBGP mesh over pod0, routes into table 48 (#576)' (#852) from feat/bird-mesh2 into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#852
2026-09-16 08:50:26 +00:00
claude-botandClaude Opus 5 3a9ea5eefe feat(bird): iBGP mesh over pod0, routes into table 48 (#576)
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing reads table 48 yet. The mesh fills it so the routing rules land on a table that is already correct, and so a mistake here shows as an empty table rather than as broken pod traffic.

No address is written down twice. Each node publishes its pod0 addresses as Talos nodeAnnotations, beside the VLANConfig that assigns them; the init container reads the node list and renders from it. bird.conf holds no address at all, and the pod CIDRs are not restated either — the own-CIDR filter keys on the outgoing interface plus a maximum-length exclusion, which separates the node's pod route from cilium_host's own host address without naming a prefix.

Rendering is go-template-file, not string building: kubectl renders the two templates, a field selector drops the node itself server-side, and in-cluster config removes the token and CA plumbing. kubectl comes from the Alpine package, so there is no version literal to drift out of sync and no download to trust; it only ever reads core/v1 Nodes. jq and curl are gone.

The init container ends with bird -p, so BIRD's own parser gates startup: a node whose annotation is missing renders as <no value> and fails there rather than crashlooping the daemon or forming a mesh with a hole.

learn all rather than learn, since plain learn skips proto kernel. direct is explicit because iBGP defaults to multihop, which would make the gateway recursive. strict bind keeps the listener off mgmt0 and ingress0.

Verified against the live cluster with all three nodes annotated via --mode=try: the templates rendered the expected defines and four peer protocols, and bird -p parsed the result inside the image with exit 0 — which also confirms net.len and the include ordering. kubectl kustomize builds.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:43:42 +02:00
k.renner b914b030cc Merge pull request 'feat(bird): add curl and jq for the peer-list init container' (#849) from feat/bird-image-api into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#849
2026-09-16 08:10:50 +00:00
claude-botandClaude Opus 5 bb3987ce1c feat(bird): add curl and jq for the peer-list init container
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The per-node peer list is rendered from the Kubernetes API rather than written into the repo a second time, so the init container needs an HTTP client that can verify against the API server's CA bundle. busybox wget cannot, and the query is not going to run unverified. Using the same image for the init container and the daemon keeps one lifecycle instead of borrowing a CI image at runtime.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:10:09 +02:00
k.renner 480b2b11e4 Merge pull request 'feat(bird): image for the pod-network BGP mesh (1/3) (#576)' (#846) from feat/bird-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#846
2026-09-16 07:50:27 +00:00
claude-botandClaude Opus 5 3302e2c5cd fix(bird): pin the base image by digest and self-test the binary
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Every other Alpine image under ci/images/ pins alpine:3.21 by index digest, and alpine-jq states the reason in the file: the resolved version drifts between restarts of an otherwise pinned image. The first revision used a bare alpine:3.22 tag. 3.22 is still right — bird2 first appears in 3.22/community — so the tag stays and the index digest is added.

heating-can runs --help checks before publishing so a wrong path fails the build rather than the first pod; bird documents --version, so it gets the same guarantee.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:38:46 +02:00
claude-botandClaude Opus 5 3792485c2f feat(bird): image for the pod-network BGP mesh (1/3) (#576)
ci/woodpecker/push/build-ci-images Pipeline was successful
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
Pod east-west follows the peer's Kubernetes InternalIP on mgmt0, and Cilium offers no way to point it elsewhere — established by test in #838, and directRoutingDevice (#837) does not do it. Policy routing does: a rule on the pod source prefixes into a table whose routes carry pod0 next-hops, proven on k8s-1 by counting 4647 packets onto pod0.

Those routes have to be generated, not written down: PodCIDRs are Kubernetes-assigned and do not survive a rebuild in place. BGP between the nodes over pod0 generates them, with the session source as the next-hop.

BIRD rather than FRR. FRR's docs describe 'set table' only as setting a BGP table identifier, never a kernel table; binding bgpd to an address implies --no_kernel and would suppress the route installation the design exists for; and a gatewayless IPv6 device route has no next-hop for its route-maps to match, so the own-CIDR filter could not be uniform. BIRD documents 'kernel table', 'strict bind' without touching the kernel protocol, and an 'ifname' filter attribute that discriminates the local route from the learned ones in both families.

No maintained standalone BIRD image exists: pierky/bird self-declares as lab-only and amd64-only, calico/bird is a Calico-internal artifact expecting confd, slipmesh/bird is five weeks old, srl-labs and ibhde are lab-scoped or on BIRD 1.6. A custom controller would have needed an image of its own anyway, so the build is common to both routes.

Image only. The DaemonSet is 2/3 and the routing rules 3/3, so the table fills before anything reads it.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:29:17 +02:00
k.renner 1a1e095f76 Merge pull request 'feat(talos): route pod egress over ingress0 (#576)' (#842) from feat/pod-egress-vlan47 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#842
2026-09-16 05:40:40 +00:00
claude-botandClaude Opus 5 11df090913 feat(talos): route pod egress over ingress0 (#576)
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 default route cannot simply move to ingress0: the node IP, the API VIP and apid answer on mgmt0, so a reply leaving over ingress0 would carry an mgmt0 source onto VLAN 47. Policy routing splits the two instead — pod source prefixes look up table 47, everything the node sends itself keeps main and mgmt0.

Table 47 holds only the two default routes, so destination rules at 990/991 send the cluster and local networks back to main first; without them a pod's packet to a service matches the source rule, finds only the default, and leaves for the UDM. That was measured on k8s-1, where in-cluster DNS stopped resolving until the rules were added.

The v6 gateway is the UDM's configured ULA on the segment, the address BGP already peers with, rather than the RA-learned link-local. accept_ra_defrtr stays 0 on ingress0 so the RA default does not land in main.

Verified on k8s-1 with --mode=try: a pod egressed as 2003:d6:973f:5605:5a47:caff:fe74:7027, the ingress0 GUA from the VLAN 47 prefix, while in-cluster DNS and the Talos and Kubernetes APIs kept working.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 07:35:14 +02:00
security-bot cfb1406201 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 21:06:23 +00:00
security-bot 4190c634ec chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-15 20:21:56 +00:00
security-bot 717c5a5f8a chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.1
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-14 19:09:30 +00:00
security-bot 4fd3c21308 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.1
Updates managed by image-automation-controller.
2026-09-14 19:04:00 +00:00
security-bot 5e79157e5e chore(image-automation): bump rhasspy/wyoming-whisper:3.8.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-14 15:51:03 +00:00
security-bot 4c36913fee chore(image-automation): bump rhasspy/wyoming-whisper:3.8.0
ci/woodpecker/cron/update-audit Pipeline was successful
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-11 21:50:19 +00:00
security-bot 605690f9ff chore(image-automation): bump ghcr.io/music-assistant/server:2.10.3
Updates managed by image-automation-controller.
2026-09-11 21:50:01 +00:00
security-bot fe6fa9ec4c chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.2
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-11 20:50:44 +00:00
security-bot c954043f90 chore(image-automation): bump gotenberg/gotenberg:8.37.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-11 15:50:15 +00:00
security-bot 31be9a115a chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.2.0
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-10 16:53:33 +00:00
security-bot deda43ab85 chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.2.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-10 16:18:57 +00:00
security-bot 19a464b2bf chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.73.0
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-09 19:49:44 +00:00
security-bot b29174dff2 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.18.1
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-09 00:49:19 +00:00
security-bot 451e5002e9 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.18.1
Updates managed by image-automation-controller.
2026-09-09 00:49:17 +00:00
k.renner d443f435aa Merge pull request 'feat(cilium): route pod east-west over pod0 (#576)' (#837) from feat/cilium-native-pod0 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#837
2026-09-08 14:44:36 +00:00
claude-botandClaude Fable 5.1 63a3aebe84 feat(cilium): route pod east-west over pod0 (#576)
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
Pod-to-pod traffic currently rides the management VLAN. In tunnel mode
Cilium encapsulates to the peer's Kubernetes InternalIP, so east-west
follows VLAN 40 wherever it goes -- which blocks moving management onto
the 1G NIC without dragging pod traffic with it.

The node IP stays on VLAN 40. Instead this uses the documented device
selection: native routing hands forwarding to the kernel, and
autoDirectNodeRoutes installs the remote PodCIDR routes, which is
permitted because all three nodes share pod0's L2 segment.

  "By default, if a single device was detected or specified via devices
   then Cilium will use that device for direct routing. Otherwise,
   Cilium will use a device with Kubernetes InternalIP or ExternalIP
   set."

directRoutingDevice is therefore set explicitly: mgmt0, ingress0 and
pod0 all carry addresses, so the fallback would pick mgmt0.

Verified beforehand that pod0 is a working path between nodes -- from a
pod on k8s-2, k8s-1's kubelet answers 401 on both 10.2.48.11:10250 and
[fd02:48::11]:10250, so the L2 and both address families work.

ipv4/ipv6NativeRoutingCIDR are already set and become mandatory in this
mode; 10.244.0.0/16 and fd10:244::/48 sit inside them, so pod-to-pod
stays unmasqueraded as before.

Not rolling: dropping encapsulation is a cluster-wide datapath change
and a node that has switched cannot exchange pod traffic with one that
has not. Needs a scheduled window, not a background reconcile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:37:59 +02:00
k.renner ee2d5d1d25 Merge pull request 'feat(cilium): peer BGP over the ingress VLAN (#576)' (#836) from feat/bgp-peer-ingress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#836
2026-09-08 13:22:05 +00:00
claude-botandClaude Fable 5.1 60b4286910 feat(cilium): peer BGP over the ingress VLAN (#576)
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 B step 2 of the traffic-class split. The eBGP session source
address is what the UDM installs as the next-hop for every PodCIDR and
LoadBalancer prefix the cluster advertises. Peering over the management
LAN therefore pinned all world->pod return traffic to the 1G mgmt link,
which is the thing #576 exists to fix. Moving the peers onto VLAN 47
puts that traffic on the 10G ingress link instead.

No localAddress or sourceInterface override is needed: the kernel
already picks the ingress0 address for both families.

  ip -6 route get fd02:47::1
  fd02:47::1 dev ingress0 proto kernel src fd02:47::5a47:caff:fe74:7027

Both peer addresses were verified reachable from the cluster before
this change -- 10.2.47.1 and fd02:47::1 each answer HTTP with a 301,
and the UDM's neighbour entry on ingress0 is REACHABLE.

The UDM already accepts sessions from this segment: PR #835 added the
10.2.47.0/24 and fd02:47::/64 listen ranges and is uploaded. Its VLAN 40
ranges stay in place for now and get dropped in step 3, once every
session is confirmed established on 47.

gracefulRestart (120s) covers the peer replacement, so the UDM holds the
learned routes across the flap rather than withdrawing the LB VIPs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:20:13 +02:00
k.renner cc52181352 Merge pull request 'feat(bgp): let the UDM accept sessions from the ingress VLAN too' (#835) from feat/bgp-listen-ingress into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#835
2026-09-08 13:00:53 +00:00
claude-bot 56d55b6228 feat(bgp): let the UDM accept sessions from the ingress VLAN too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First step of moving the world-to-pod class onto ingress0 (#576). This
changes nothing about where the cluster peers from — it only widens
what the UDM will accept.

The order matters. Cilium's sessions are outbound to the UDM, and the
UDM accepts them through `bgp listen range`. Moving the cluster to
ingress0 before the UDM listens on 10.2.47.0/24 would drop every
session, and with them every advertised LoadBalancer VIP, so external
ingress would go down until the UDM caught up. Listing both segments
first removes that window entirely: a listen range only accepts a
session, it never opens one, so the 47 ranges sit idle until the
cluster actually moves.

The prefix-lists and route-maps are untouched. They filter which
prefixes the UDM accepts — pod CIDRs and the LB pool — and the cluster
advertises the same prefixes either way. Only the next hop changes.

Uploaded by hand at UniFi UI -> Settings -> Routing -> BGP, as before.
Verify with `vtysh -c "show ip bgp summary"`: the six existing sessions
on 10.2.40.x and fd40:: must still be established afterwards, and no
new ones appear until the cluster side moves.

The 40 ranges come out in a later change, once every session is
established on 47.

Refs #576
2026-09-08 14:57:49 +02:00
k.renner 6416f2f609 Merge pull request 'fix(talos): address ingress0 statically, leave mgmt0 the only DHCP client' (#833) from feat/ingress-static-addressing into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#833
2026-09-08 12:19:59 +00:00
claude-bot 80c2def518 fix(talos): stop leasing ingress0, leave mgmt0 the only DHCP client
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
Adding a second DHCP client per node in #831 broke node DNS. Both
clients announce the same hostname, so the UDM registered each node
against VLAN 47 instead of VLAN 40: `k8s-1.srv.renner.uno` and
`k8s-3.srv.renner.uno` stopped resolving, and the reverse for
10.2.47.11 came back as bare `k8s-1`. The lease's resolver also
displaced the management one, leaving 10.2.47.1 ahead of 10.2.40.1 in
the node's DNS servers.

That matters beyond `talosctl -n <fqdn>`: bootstrap.sh resolves node
FQDNs in the talos phase, so a rebuild depends on those records.

The UDM cannot register a network's clients selectively, so the client
goes rather than the record. DHCP is now off on VLAN 47 entirely and
ingress0 declares .11/.12/.13 by node ordinal, matching pod0's
numbering. Its default route is declared with the same losing metric
2048 the lease supplied, so nothing about routing changes: mgmt0 keeps
both default routes until the flip.

IPv4 only. The problem was DHCPv4 announcing a hostname; IPv6 never had
one. The prefix is announced correctly, so ingress0 keeps taking its
IPv6 address by SLAAC exactly as mgmt0 takes its fd40:: address —
derived from the MAC, stable across reboots, nothing to allocate.

accept_ra therefore stays at its default of 2. An IPv6 default router
is a link-local address that can only be learned from the RA, and with
net.ipv6.conf.all.forwarding=1 — which Cilium sets — Linux ignores RAs
below 2 entirely. Only accept_ra_defrtr is set to 0, because mgmt0 owns
the default until the flip; dropping that one line is what later hands
the IPv6 default to ingress0.

pod0 keeps accept_ra 0: it is a closed segment with no router on it, so
there is genuinely nothing to learn.

No reboot needed. Address, route and sysctl changes apply live, and the
whole change is provable per node with `--mode=try`, which reverts
itself.

Refs #576
2026-09-08 14:13:57 +02:00
k.renner 0ca1753418 Merge pull request 'feat(talos): role-named links, and the pod and ingress VLANs' (#831) from feat/k8s-net-split-phase-a 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
Reviewed-on: k.renner/homelab#831
2026-09-08 10:27:56 +00:00
claude-botandClaude Fable 5.1 84b415a0e0 feat(talos): role-named links, and the pod and ingress VLANs
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
First pass of the traffic-class split (#576). Purely additive: nothing
moves onto the new segments yet.

Links by role, not by PCI topology. A udev rule per node matches the
burned-in MAC and renames at boot, the way ceph-san already did:
enp2s0f0np0 -> uplink, enp87s0 -> mgmt. Shared manifests now name roles
and only each node's own patch knows the hardware. The two comments
elsewhere that named the old links go with them.

Two new VLANs on the uplink, both created on the UDM already:

- pod0, VLAN 48. Node-to-node only; the overlay rides it once the
  kubelet node IP moves. Statically addressed .11/.12/.13 out of
  10.2.48.0/24 and fd02:48::/64, no gateway, no DHCP, accept_ra 0. A
  lease would make the cluster's data path depend on the UDM being up in
  order to form, on a segment whose traffic never reaches the UDM.
- ingress0, VLAN 47. World-to-pod: BGP session, LoadBalancer next hop,
  masquerade source. Publicly routed, so DHCP — but at routeMetric 2048
  and with accept_ra_defrtr 0, because DHCPv4Config can only
  deprioritise a default route, not suppress one, and two defaults at
  1024 tie.

mgmt0 keeps the node IP and both default routes. A mistrunked VLAN 47 or
48 therefore cannot strand a node, and the flip is a later change that
needs no reboot and is provable with `--mode=try`.

Applying needs a rolling reboot: a udev rule cannot rename a link on a
running node (Resource busy). Drain first, one node at a time, and wait
for CephCluster HEALTH_OK rather than node Ready between them.

Refs #576

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:43:33 +02:00
k.renner c0fa69321b Merge pull request 'chore(ci): run pipelines on ci-k8s:2026-09-08' (#829) from chore/ci-k8s-2026-09-08 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#829
2026-09-08 07:42:15 +00:00
claude-botandClaude Fable 5.1 f774ca4bca chore(ci): run pipelines on ci-k8s:2026-09-08
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 tag build-ci-images published from #827 (pipeline 8573): talosctl
v1.13.9, kubectl v1.36.3, flux 2.9.5. Six references moved from
2026-08-10.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 09:35:49 +02:00
k.renner 30362e23d7 Merge pull request 'feat(flux): upgrade components to v2.9.5' (#828) from feat/flux-2.9.5 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#828
2026-09-08 07:33:27 +00:00
k.renner 7efa556397 Merge pull request 'chore(ci): bump CI tool pins to match the deployed cluster' (#827) from feat/ci-tool-pins into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#827
2026-09-08 07:33:08 +00:00
k.renner 4f422eb7de Merge pull request 'feat(bootstrap): OpenBao restore and bring-up under the static seal' (#826) from feat/openbao-seal-key-bootstrap into master
Reviewed-on: k.renner/homelab#826
2026-09-08 07:32:33 +00:00
claude-botandClaude Fable 5.1 6bc23cd306 feat(flux): upgrade components to v2.9.5
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
Regenerated with `flux install --export
--components-extra=image-reflector-controller,image-automation-controller`
from the v2.9.5 CLI; the same component set as before.

Controllers: source v1.8.3 -> v1.9.5, kustomize v1.8.4 -> v1.9.5, helm
v1.5.4 -> v1.6.4, notification v1.8.4 -> v1.9.4, image-reflector v1.1.1
-> v1.2.5, image-automation v1.1.2 -> v1.2.5. CRDs updated, the
allow-webhooks NetworkPolicy now names port 9292.

Prerequisites from the 2.9 release notes, verified before this commit:
the removed image v1beta2 and notification v1beta2 API versions are
neither used in the repo (image.* all v1, Alert/Provider v1beta3) nor
stored in the cluster's CRDs (storedVersions all current), so no
`flux migrate` step is needed. helm-controller 1.6 changes the default
post-render strategy; no HelmRelease here uses postRenderers.
`flux check --pre` passes on Kubernetes 1.36.3 and the manifests pass a
server-side dry run against the live cluster.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 09:01:22 +02:00
claude-botandClaude Fable 5.1 6b0617b4a7 chore(ci): bump CI tool pins to match the deployed cluster
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
From the update audit (#426): the tools CI drives the cluster with
trailed what the cluster runs.

ci/images/k8s/Containerfile
- talosctl v1.12.6 -> v1.13.9, the Talos version on the nodes. An older
  talosctl can misjudge or reject config generated for a newer machine.
- kubectl v1.36.2 -> v1.36.3, the cluster's Kubernetes version.
- flux CLI 2.9.2 -> 2.9.5, the current 2.9.x. Checksums verified against
  the upstream release assets (talos sha256sum.txt, flux checksums.txt)
  and against a local download.

.woodpecker/ci-manifests-test.yml
- woodpecker-cli v3.17.0 -> v3.18.0, the version the server and agent
  already run; the lint step was one minor behind its own server.

Merging triggers build-ci-images (path ci/images/**), which publishes
ci-k8s:<date>. The six pipelines pinned to ci-k8s:2026-08-10 move to
that tag in a follow-up once it exists.

Refs #426

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:58:35 +02:00
claude-botandClaude Fable 5.1 193bcb362e fix(openbao): QuorumLost runbook text must not point at the recovery key
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 description told on-call to try the recovery key as a last resort,
while RUNBOOK Step 4 in the same change (and upstream: recovery keys are
"purely an authorization mechanism") says it cannot unseal an
auto-sealed server. Say what actually helps: fix the Secret, restart
the pod. Also drop a duplicate root-token computation in
restore_openbao.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:35:01 +02:00
claude-botandClaude Fable 5.1 35bad376b8 feat(bootstrap): OpenBao restore and bring-up under the static seal
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
Follow-up to #825 (static-seal auto-unseal). The rebuild path and the
operator docs still assumed shamir with a manual unseal per replica.

bootstrap.sh
- Required secrets: `seal-key` (pass show homelab/openbao/seal-key)
  replaces `unseal-key`. The snapshots are encrypted under the seal key,
  so it is the DR-critical secret now; the former unseal key is the
  recovery key and is not needed for a restore (upstream: recovery keys
  "are purely an authorization mechanism" and cannot unseal a server
  whose auto-seal is broken).
- flux phase creates the `openbao-seal-key` Secret (namespace + Secret,
  before `flux bootstrap`) from the collected key. --from-literal via a
  substitution, because `pass show` appends a newline and OpenBao
  rejects a key that is not exactly 32 bytes of base64 ("unknown
  encoding for AES-256 key" — verified). An existing Secret with the
  same value is left alone; a different value is refused, since the
  data is written under whichever key OpenBao started with.
- restore_openbao: `init -recovery-shares=1 -recovery-threshold=1`,
  root token from the output, wait for the self-unseal (init returns
  with the node sealed; the server's own retry loop unseals it within
  ~5 s), then `snapshot restore` WITHOUT -force: OpenBao verifies the
  snapshot was written under this server's seal key and refuses a
  mismatch by name, which is the one guard against restoring under a
  wrong Secret. -force skipped that check and would leave a server that
  can never unseal. The throwaway `unseal` step and its argv/tty
  discussion are gone — under an auto-seal there is no unseal key.
- unseal_openbao -> bring_up_openbao: waits for spec.replicas pods to
  report sealed=false, walking the OrderedReady chain as before, and on
  timeout prints each still-sealed pod's `bao status` instead of a bare
  "timed out".

Rehearsed locally with two OpenBao servers sharing a static key (file://
form, bao 2.5.5): init on an auto-seal returns an empty unseal_keys_b64,
one recovery key and a root token and the server is unsealed within
seconds; a restore of A's snapshot into fresh B (both with and without
force) re-seals and reloads the root key via the auto seal ~5 s later
("done reloading root key using auto seal"), after which B serves A's
data with A's root token. Restarting B under a different key leaves it
sealed with "failed to decrypt keys from storage: message
authentication failed".

Docs and alerts: RUNBOOK-cold-rebuild Step 4 and MIGRATION-sops-openbao
describe the auto-seal flow and the recovery-key semantics; the
OpenBao alert descriptions and the snapshot CronJob comment no longer
tell the reader to unseal by hand. vmtest-job comment updated.

Not covered: a full VMTEST_FULL=1 rehearsal through the flux phase. The
wiki records none ever reaching past the Flux bootstrap checkpoint, so
that gap predates this change.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 08:23:08 +02:00
k.renner 6b7736ea58 Merge pull request 'fix(alerts): page the moment OpenBao is below quorum' (#824) from fix/openbao-alerts-immediate into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#824
2026-09-08 06:02:32 +00:00
k.renner 7ff20e5b68 Merge pull request 'feat(openbao): auto-unseal with a static seal' (#825) from feat/openbao-static-seal into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#825
2026-09-08 05:54:54 +00:00
claude-botandClaude Fable 5.1 296accd1cb feat(openbao): auto-unseal with a static seal
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
Every restart sealed the replica until a human unsealed it. On
2026-09-07 a rolling talosctl upgrade rebooted all three nodes; the PDB
refused the second eviction while the first replica was sealed, Talos
waited out its 5-minute drain timeout and rebooted anyway, and raft
quorum was gone for 24 minutes — every ExternalSecret and the
Kustomizations behind them with it. A power loss would do the same
with no timeout to blame.

OpenBao 2.6.1 supports a static seal: a 32-byte key the server uses to
unseal itself. The key is generated into pass (homelab/openbao/seal-key)
and created as the openbao-seal-key Secret before this release reconciles;
the chart hands it to the server as BAO_SEAL_KEY from that Secret.

This overturns the principle in bootstrap.sh that the key protecting
OpenBao must not live in the cluster OpenBao secures. Decided by the
owner on 2026-09-08: the alternatives were a transit seal from a second
OpenBao on edge-1, which raft traffic would wear out, or a workstation
unseal script that may not be at hand when needed. The exposure is
bounded by what ESO already does — every synced secret is plaintext in
etcd (encrypted at rest) — and grows only by what lives solely in
OpenBao.

Migration is a live, per-replica procedure with the shamir key becoming
the recovery key; the StatefulSet is OnDelete, so merging this changes
nothing until each pod is restarted in turn. The bootstrap and rebuild
paths (bootstrap.sh, vmtest) follow in a separate change once the
migration is verified.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:52:27 +02:00
claude-botandClaude Fable 5.1 2665e5ebf3 fix(alerts): page the moment OpenBao is below quorum
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
OpenBaoQuorumLost waited for: 5m. Below quorum the secret store is
down — the ClusterSecretStore goes InvalidProviderConfig and every
ExternalSecret and the Kustomizations behind them stall — and a sealed
replica never recovers on its own, so the window only delayed the page.
It fires on the first evaluation now.

OpenBaoUnavailable, the external probe, drops from 5m to 1m: two
consecutive failed probes, enough to skip a single lost probe through
Traefik, not enough to sit on a sealed store.

Both StatefulSet expressions are wrapped in max by (namespace,
statefulset). kube-state-metrics stamps instance/pod on the series, so
a rescheduled exporter starts a new series and Prometheus resets the
for: timer with it — on 2026-09-07 that made OpenBaoReplicaNotReady fire
14 minutes late. Alertmanager routes match alertname/source only, so the
dropped labels change nothing downstream.

Refs #805

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:40:12 +02:00
k.renner 0fe512746b Merge pull request 'feat(monitoring): Intel iGPU dashboard' (#823) from feat/intel-gpu-dashboard into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#823
2026-09-08 05:24:35 +00:00
claude-botandClaude Fable 5.1 4e2303f455 feat(monitoring): Intel iGPU dashboard
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
Grafana dashboard for the gpumon_* series from intel-gpu-exporter:
current Render/3D busy, GPU power and GT frequency per node as stat
tiles, then one time series per engine (Render/3D, Video, VideoEnhance,
Blitter) with node as the series, GT frequency, GPU and package power,
and memory-controller read/write bandwidth.

Node colours are fixed per node across every panel — blue, orange, aqua
for k8s-1/2/3 — rather than assigned by series order, so a node keeps
its colour whatever the $node filter shows. The trio validates for
colour-vision deficiency and normal vision on both Grafana themes
(worst all-pairs ΔE 9.4 / 20.9). Reads and writes share one axis and
differ by dash, not hue. One axis per panel throughout.

gpumon_clients_count is deliberately absent: per-client attribution
needs hostPID, which the exporter does not have, so it would read 0
forever and imply an idle GPU.

Refs #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:22:46 +02:00
k.renner 483093a104 Merge pull request 'feat(monitoring): export Intel iGPU utilisation, frequency and power' (#822) from feat/intel-gpu-exporter into master
Reviewed-on: k.renner/homelab#822
2026-09-08 05:18:43 +00:00
claude-botandClaude Fable 5.1 8d348e7f88 fix(monitoring): let intel_gpu_top pick the GPU instead of hard-coding card0
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 -device flag named /dev/dri/card0. That is the right node today on
every host, but nothing guarantees the number: a second GPU or a
different probe order and the exporter would silently watch the wrong
device or none. The plugin's monitoring resource injects every GPU on
the node and intel_gpu_top selects it on its own — verified with a probe
pod on k8s-2, which produced the same gpumon_* series without the flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:15:04 +02:00
claude-botandClaude Fable 5.1 6f9ff9d624 feat(monitoring): export Intel iGPU utilisation, frequency and power
A DaemonSet running clambin/intel-gpu-exporter (intel_gpu_top behind a
Prometheus endpoint) with a ServiceMonitor, giving gpumon_engine_usage,
gpumon_frequency, gpumon_power, gpumon_imc_* and gpumon_clients_count
per node.

Privilege model, validated with a one-shot pod before this commit:
/dev/dri comes from the device plugin's gpu.intel.com/monitoring
resource (every GPU on the node, no hostPath); the i915 PMU that
intel_gpu_top reads is a system-wide event source and the nodes run
perf_event_paranoid=3, so CAP_PERFMON is the one capability added.
No privileged, no hostPID, no hostNetwork, read-only root filesystem.
Root rather than a fixed uid because the capability has to be effective
without file capabilities on the binary.

End-to-end check on k8s-2 during a 40 s chat completion on llama-server:
Render/3D busy 98 %, actual frequency 1449 MHz, GPU power 18.8 W, while
the pod's CPU stayed near 100m — the "is it really on the GPU" question
this exists to answer.

XPU Manager was ruled out (Arc Pro / data-centre parts only); the
archived onedr0p exporter needs privileged + hostPID.

Fixes #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 07:14:24 +02:00
k.renner 7bf65783be Merge pull request 'feat(intel-gpu-plugin): advertise the monitoring resource' (#820) from feat/intel-gpu-monitoring-resource into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#820
2026-09-08 04:54:15 +00:00
k.renner dd9fb01359 Merge pull request 'fix(reloader): restart audiobookshelf and immich-server on ConfigMap change' (#819) from fix/reloader-configmap-annotations into master
Reviewed-on: k.renner/homelab#819
2026-09-08 04:52:32 +00:00
claude-botandClaude Fable 5.1 da8a725b32 feat(intel-gpu-plugin): advertise the monitoring resource
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Enables -enable-monitoring on the device plugin. With 0.36.0 that
advertises gpu.intel.com/monitoring, a single resource per node that
hands a pod every GPU rather than one of the shared slots; it is what a
metrics exporter requests to see the whole iGPU, and it is the only way
to get /dev/dri into a pod without a hostPath.

Nothing consumes it yet. The nodes currently advertise gpu.intel.com/i915
only — the issue assumed the chart default had this on, but the
DaemonSet here is hand-written and did not.

Refs #752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 06:51:43 +02:00
claude-botandClaude Fable 5.1 6008d59c9f fix(reloader): restart audiobookshelf and immich-server on ConfigMap change
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
Both consume their ConfigMap with envFrom, which Kubernetes does not
watch: a changed value applies in git and in the cluster while the
running container keeps the environment it started with. Paperless hit
exactly this in #739 and got the annotation in #741; these two were the
remaining envFrom consumers without one.

ftp-scanner, the third Deployment named in the issue, needs nothing: its
ConfigMap comes from a configMapGenerator that deliberately keeps the
content-hash suffix, so any change renames the ConfigMap, mutates the
pod template and rolls the pod on its own.

Reloader already watches all namespaces (kubernetes/infrastructure/reloader).

Fixes #742

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 06:46:12 +02:00
k.renner 5aa6c0609d Merge pull request 'fix(netbird-proxy-services): pin the digest the master build produced' (#818) from netbird-proxy-protocol-digest into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#818
2026-09-07 11:52:17 +00:00
claude-botandClaude Fable 5.1 0ebe113abb fix(netbird-proxy-services): pin the digest the master build produced
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
#817 pinned the digest from the branch build (pipeline 8452). Merging
#816 rebuilt the same date tag on master (pipeline 8457), Gitea kept
only the new manifest, and the pod has been in ImagePullBackOff since:
"failed to resolve reference ...@sha256:0d266b02...". With the Recreate
strategy no controller is running, so the services are unchanged.

The kaniko step of the master build pushed sha256:caffa308...; that is
the digest to pin. Comment states the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:47:22 +02:00
k.renner 1723f73b5d Merge pull request 'feat(netbird-proxy-services): roll the PROXY-protocol build (2/2)' (#817) from netbird-proxy-protocol-deploy into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#817
2026-09-07 11:34:52 +00:00
k.renner 8d8df3b635 Merge pull request 'feat(netbird): carry the WAN client address through the passthrough edge (1/2)' (#816) from netbird-proxy-protocol into master
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#816
2026-09-07 11:34:43 +00:00
claude-botandClaude Fable 5.1 29c91769a7 feat(netbird-proxy-services): roll the PROXY-protocol build (2/2)
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
Pins the controller image by digest. The registry tag is the build
date and pipeline 8452 rebuilt today's tag in place, so the tag alone
cannot carry a new build to the cluster; the digest can.

Merge only after the Traefik change from 1/2 has reconciled: from this
build on every service target sends a PROXY protocol v2 header, and
Traefik drops such connections unless websecure trusts the pod CIDRs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:32:06 +02:00
claude-botandClaude Fable 5.1 2c47c68a8b feat(netbird): carry the WAN client address through the passthrough edge
ci/woodpecker/push/build-ci-images Pipeline was successful
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
TLS-passthrough services hand Traefik a raw TCP stream, so every WAN
request now logs the routing peer's pod IP (10.244.4.64/.112 in the
access log) instead of the client. CrowdSec's detection parses exactly
that log; with the HTTP-mode services gone it would attribute every
external request to two pod IPs, and a re-armed bouncer would repeat the
2026-06-27 mass ban.

The controller now sets options.proxy_protocol on every target (PROXY
protocol v2, TCP/TLS modes only per NetBird) and treats a service
without it as out of date, so the eleven existing services are updated
on the next pass. Traefik's websecure entrypoint accepts the header from
the pod CIDRs, mirroring forwardedHeaders.trustedIPs. Traefik takes
connections with or without a header from a trusted IP, so mesh and LAN
traffic that carries none is unaffected and the Traefik change can land
before the controller image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:29:48 +02:00
k.renner ceff817d88 Merge pull request 'fix(netbird-proxy-services): numeric runAsUser so runAsNonRoot can be verified' (#815) from netbird-proxy-services-uid into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#815
2026-09-07 10:48:04 +00:00
194 changed files with 6946 additions and 2649 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ when:
steps:
- name: fetch-alerts
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
@@ -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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+32 -6
View File
@@ -350,17 +350,17 @@ steps:
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
# netbird-proxy-services: the controller that reconciles NetBird reverse-proxy
# netbird-service-sync: the controller that reconciles NetBird reverse-proxy
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-proxy-services
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/netbird-proxy-services
directory: ci/images/netbird-service-sync
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/netbird-proxy-services/**", ".woodpecker/build-ci-images.yml"]
path: ["ci/images/netbird-service-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
@@ -374,5 +374,31 @@ steps:
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-service-sync:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/bird
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/bird/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/bird:$TAG \
--destination gitea.renner.uno/gitea_admin/bird:latest
+17 -21
View File
@@ -14,8 +14,8 @@
# and ci-claude:latest. The Containerfile makes this layer
# byte-reproducible, so rebuilding an unchanged version dedups
# in the registry instead of orphaning a fresh ~260 MB copy.
# 3. bump-pr — (only when the version actually changed) rewrite the 5
# consumer pins to ci-claude:<version> and open a PR. A human
# 3. bump-pr — (only when any pin is off the latest version) rewrite every
# consumer pin to ci-claude:<version> and open a PR. A human
# reviews and merges it; nothing auto-merges.
#
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
@@ -107,31 +107,22 @@ steps:
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "npm returned an implausible version '$$NEW' — aborting."; exit 1 ;;
esac
# The tag every consumer currently references. They are kept identical,
# so this must resolve to EXACTLY ONE non-empty tag; anything else
# (no match, or a partial/disagreeing set) aborts rather than risk a
# corrupting sed later. Counting with `grep -c .` (non-empty lines)
# instead of `wc -l` is deliberate: an empty result yields 0 here but
# 1 under wc -l, which would slip past the guard.
CUR=$$(grep -hoE 'ci-claude:[0-9A-Za-z._-]+' $$CONSUMERS | sed 's/^ci-claude://' | sort -u)
NTAGS=$$(printf '%s' "$$CUR" | grep -c . || true)
if [ -z "$$CUR" ] || [ "$$NTAGS" -ne 1 ]; then
echo "Expected exactly one ci-claude tag across the consumers, got [$$CUR]."
echo "Refusing to guess — fix the pins by hand."; exit 1
fi
echo "latest=$$NEW current=$$CUR event=$$CI_PIPELINE_EVENT"
# Distinct tags the consumers currently reference. Divergent pins are
# normal (a pipeline bumped on its own); the bump rewrites all of them.
CUR=$$(grep -hoE 'ci-claude:[0-9][0-9A-Za-z._-]*' $$CONSUMERS | sed 's/^ci-claude://' | sort -u | tr '\n' ' ' | sed 's/ $$//')
[ -n "$$CUR" ] || { echo "No ci-claude pin found in the consumers — aborting."; exit 1; }
echo "latest=$$NEW current=[$$CUR] event=$$CI_PIPELINE_EVENT"
printf '%s' "$$NEW" > "$$CI_WORKSPACE/.ci-refresh/version"
printf '%s' "$$CUR" > "$$CI_WORKSPACE/.ci-refresh/current"
# Build when the version changed, or on a push (Containerfile edit must
# ship even if the version is unchanged). Bump the pins only when the
# version the consumers reference is not the latest.
# Build when any pin is off latest, or on a push (Containerfile edit must
# ship even if the version is unchanged). Bump when any pin is off latest.
if [ "$$CUR" != "$$NEW" ] || [ "$$CI_PIPELINE_EVENT" = "push" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-build"; echo "will build $$NEW"
else
echo "already on $$NEW — nothing to build"
fi
if [ "$$CUR" != "$$NEW" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR $$CUR -> $$NEW"
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR [$$CUR] -> $$NEW"
else
echo "consumers already pin $$NEW — no PR"
fi
@@ -175,6 +166,9 @@ steps:
- |
[ -f "$$CI_WORKSPACE/.ci-refresh/do-bump" ] || { echo "no version change — no PR"; exit 0; }
set -eu
# Secret values can carry a trailing newline; it breaks the push URL and the auth header.
PUSH_TOKEN=$$(printf '%s' "$$PUSH_TOKEN" | tr -d '\r\n')
[ -n "$$PUSH_TOKEN" ] || { echo "PUSH_TOKEN is empty — aborting"; exit 1; }
NEW=$$(cat "$$CI_WORKSPACE/.ci-refresh/version")
CUR=$$(cat "$$CI_WORKSPACE/.ci-refresh/current")
# Both were validated non-empty by the decide step; re-assert before a
@@ -190,9 +184,11 @@ steps:
# is not a maintained list.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
# Exact-string swap of the current tag for the new one, consumers only.
# NEW goes into a sed replacement: allow only a plain x.y.z version.
printf '%s' "$$NEW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "refusing non-x.y.z version '$$NEW'"; exit 1; }
# Every version-pinned tag becomes NEW, whatever it was; consumers only.
for f in $$CONSUMERS; do
sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f"
sed -i "s#ci-claude:[0-9][0-9A-Za-z._-]*#ci-claude:$$NEW#g" "$$f"
done
# Author is claude-bot (the PAT's account); the commit subject makes
# the automated-bump nature clear.
+3 -3
View File
@@ -60,7 +60,7 @@ steps:
# run that never touched them. Tracked in #461.
- name: kustomize-build
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- |
set -eu
@@ -158,7 +158,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.17.0-alpine
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
commands:
- |
set -eu
@@ -246,7 +246,7 @@ steps:
# this step fails permanently on its own documentation.
- name: no-digest-pins
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
commands:
- |
set -eu
+1 -1
View File
@@ -17,6 +17,6 @@ when:
steps:
- name: model-selection-tests
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-model-selection.sh
-3
View File
@@ -4,9 +4,6 @@ when:
branch: master
path:
- "ansible/roles/netbird-server/**"
# install-netbird-server.yml also applies the netbird-proxy-watchdog role,
# so changes to it must redeploy this host too.
- "ansible/roles/netbird-proxy-watchdog/**"
- "ansible/plays/install-netbird-server.yml"
- ".woodpecker/deploy-netbird-server.yml"
- event: deployment
+1 -1
View File
@@ -4,7 +4,7 @@ when:
steps:
- name: e2e
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+2 -2
View File
@@ -26,12 +26,12 @@ 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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
commands:
- sh scripts/ci/test-model-selection.sh
- name: e2e-test
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
+2 -2
View File
@@ -49,7 +49,7 @@ steps:
# (the issue-pick logic needs jq, which only the ci-claude image has);
# minting a 1h read-only token per run is cheap. Degrades to
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
@@ -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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
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.250
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+198
View File
@@ -0,0 +1,198 @@
# Nightly wiki maintenance for the Notes vault (k.renner/notes), replacing the
# workstation's wiki-maintenance and wiki-consolidate timers: distill the raw/
# backlog into wiki/ (wiki-distill agent), audit wiki/ with the vault's own
# script (structural checks, no model), then consolidate (wiki-consolidate
# agent: verifies a cohort of notes against sources, repos and the live
# cluster and edits them). The pipeline commits and pushes as Claude Bot; the
# push webhook then refreshes the wiki service.
#
# The agents address the vault as ~/Notes and the repos as ~/source/<repo>, so
# each agent step links those into the workspace instead of rewriting them.
when:
- event: cron
cron: wiki-maintenance
# Hand-started runs: `woodpecker pipeline create k.renner/homelab --branch master`.
# The typesafe_api_key secret is scoped to cron and manual, not deployment.
- event: manual
steps:
- name: checkout-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
# Token only on the wire, never in .git/config. Secrets set from files carry
# a trailing newline (run 10220: "could not read Username"); strip CR/LF.
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
clone --depth 50 --branch main https://gitea.renner.uno/k.renner/notes.git notes
echo "raw backlog: $$(ls notes/raw/*.md | grep -v _index.md | wc -l) file(s)"
# Full history for the consolidate agent's `git log` checks; the
# workspace clone is shallow and tree-filtered.
mkdir -p sources
git clone --quiet https://gitea.renner.uno/k.renner/homelab.git sources/homelab
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" \
clone --quiet https://gitea.renner.uno/k.renner/homeassistant.git sources/homeassistant
- name: fetch-diag
# Read-only live-cluster access for the consolidate agent, same mechanism
# as issue-fix.yml: ./.ci-diag/kubectl plus a 1h ci-diagnostics token.
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
environment:
SOPS_AGE_KEY:
from_secret: sops_age_key
S3_ACCESS_KEY_ID:
from_secret: hetzner_s3_access_key
S3_SECRET_ACCESS_KEY:
from_secret: hetzner_s3_secret_key
commands:
- bash scripts/ci/fetch-kubeconfig.sh
- bash scripts/ci/make-diag-kubeconfig.sh
- name: distill
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
# An empty queue must not start the agent: its startup context alone
# costs more than the whole audit.
BACKLOG=$$(ls notes/raw/*.md | grep -v _index.md | wc -l)
if [ "$$BACKLOG" -eq 0 ]; then
echo "distill: raw/ is empty, skipping the agent"
exit 0
fi
ln -sfn "$$PWD/notes" "$$HOME/Notes"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
"$$HOME/Notes/bin/wiki_rank.py" --probe-index
MODEL=$$(sh scripts/ci/pick-model.sh "distill raw source notes into atomic wiki notes: read sources, check the wiki for existing coverage, write or merge notes; careful writing, moderate reasoning")
if [ -n "$$MODEL" ]; then echo "distill: selected model $$MODEL"; else echo "distill: using the CLI default model"; fi
cd notes
cat ../ci/agents/wiki-maintenance/distill.md | claude $${MODEL:+--model $$MODEL} \
--agent wiki-distill \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(/root/Notes/bin/wiki_rank.py:*),Bash(/root/Notes/bin/wiki_ask.py:*),Bash(mv /root/Notes/raw/:*),Bash(mkdir -p /root/Notes/:*),Bash(~/Notes/bin/wiki_rank.py:*),Bash(~/Notes/bin/wiki_ask.py:*),Bash(mv ~/Notes/raw/:*),Bash(mkdir -p ~/Notes/:*)" \
--settings ../ci/agents/wiki-maintenance/settings.json \
-p
- name: audit
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
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
pull: true
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
TYPESAFE_API_KEY:
from_secret: typesafe_api_key
WIKI_INDEX_BASIC_AUTH:
from_secret: wiki_index_basic_auth
commands:
- |
set -eu
ln -sfn "$$PWD/notes" "$$HOME/Notes"
ln -sfn "$$PWD/sources" "$$HOME/source"
mkdir -p "$$HOME/.config/wiki-index"
printf '%s' "$$WIKI_INDEX_BASIC_AUTH" | tr -d '\r\n' > "$$HOME/.config/wiki-index/basic-auth"
chmod 600 "$$HOME/.config/wiki-index/basic-auth"
if [ -f .ci-diag/kubeconfig ]; then
ln -sf "$$PWD/.ci-diag/kubectl" /usr/local/bin/kubectl
export KUBECONFIG="$$PWD/.ci-diag/kubeconfig"
else
echo "consolidate: no ci-diag kubeconfig, live cluster checks unavailable"
fi
cd notes
# JSON output: the agent's report and the run's cost come out of one
# record at the end instead of a 45-minute silent log.
# One source for the Bash allowlist: the settings file (run 10608: the
# agent writes ~/Notes, the rules carried only /root/Notes).
BASH_RULES=$$(jq -r '[.permissions.allow[] | select(startswith("Bash("))] | join(",")' ../ci/agents/wiki-maintenance/consolidate-settings.json)
rc=0
cat ../ci/agents/wiki-maintenance/consolidate.md | claude \
--agent wiki-consolidate \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep,Agent,$$BASH_RULES" \
--settings ../ci/agents/wiki-maintenance/consolidate-settings.json \
--output-format json \
-p > /tmp/consolidate.json || rc=$$?
# A killed or crashed claude leaves no valid JSON; report rc, not a jq error.
if [ -s /tmp/consolidate.json ] && jq -e . /tmp/consolidate.json > /dev/null; then
jq -r '.result // empty' /tmp/consolidate.json
jq -c '{duration_ms, num_turns, total_cost_usd, is_error}' /tmp/consolidate.json
else
echo "consolidate: no result record (exit $$rc)"; head -c 2000 /tmp/consolidate.json || true
fi
echo "consolidate: git log --oneline of the run:"
git log --oneline origin/main..HEAD
exit $$rc
- name: push-notes
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.280
pull: true
environment:
WIKI_BOT_TOKEN:
from_secret: wiki_bot_token
commands:
- |
set -eu
cd notes
git config user.email "claude-bot@renner.uno"
git config user.name "Claude Bot"
# Only the vault content is committed; anything an agent leaves elsewhere
# in the checkout (run 10362: scratch scripts in the root) stays behind.
# The consolidate agent commits its own snapshots, so HEAD may already be
# ahead of origin/main with a clean tree.
git add -A -- wiki raw output .wiki-consolidation-state.json
git diff --cached --quiet || git commit -q -m "wiki maintenance $$(date -u +%F): distill + audit + consolidate (CI run $$CI_PIPELINE_NUMBER)"
if [ "$$(git rev-list --count origin/main..HEAD)" -eq 0 ]; then
echo "wiki-maintenance: no changes"
exit 0
fi
# The workstation pushes main on its own (notes-push.path), so main moves
# while the agents run (run 10241: non-fast-forward). The run's work goes
# to a branch and a PR that this step merges through Gitea; a conflict
# leaves the PR open for a human and fails the run.
BRANCH="wiki-maintenance/$$(date -u +%F)-$$CI_PIPELINE_NUMBER"
git checkout -q -b "$$BRANCH"
TOKEN=$$(printf '%s' "$$WIKI_BOT_TOKEN" | tr -d '\r\n')
git -c http.extraheader="AUTHORIZATION: token $$TOKEN" push origin "HEAD:$$BRANCH"
API="https://gitea.renner.uno/api/v1/repos/k.renner/notes"
jq -n --arg title "wiki maintenance $$(date -u +%F) (CI run $$CI_PIPELINE_NUMBER)" \
--arg body "Nightly distill + audit + consolidate. Merged by the pipeline; open only if the merge failed." \
--arg head "$$BRANCH" '{title: $$title, body: $$body, head: $$head, base: "main"}' > /tmp/pr.json
code=$$(curl -s -o /tmp/pr-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" --data @/tmp/pr.json "$$API/pulls")
echo "POST pull -> HTTP $$code"
[ "$$code" -ge 200 ] && [ "$$code" -lt 300 ] || { cat /tmp/pr-resp.json; exit 1; }
NUM=$$(jq -r '.number' /tmp/pr-resp.json)
# Merge without delete_branch_after_merge: run 10254 got HTTP 500 from that
# call although the merge itself had landed. Verify the PR state instead of
# trusting the status code, then drop the branch as a separate, best-effort step.
code=$$(curl -s -o /tmp/merge-resp.json -w '%{http_code}' -X POST \
-H "Authorization: token $$TOKEN" -H "Content-Type: application/json" \
--data '{"Do":"merge"}' "$$API/pulls/$$NUM/merge")
echo "POST merge #$$NUM -> HTTP $$code"
MERGED=$$(curl -s -H "Authorization: token $$TOKEN" "$$API/pulls/$$NUM" | jq -r '.merged')
echo "PR #$$NUM merged: $$MERGED"
[ "$$MERGED" = "true" ] || { cat /tmp/merge-resp.json; echo "PR #$$NUM left open"; exit 1; }
code=$$(curl -s -o /tmp/del-resp.json -w '%{http_code}' -X DELETE -H "Authorization: token $$TOKEN" \
"$$API/branches/$$(printf '%s' "$$BRANCH" | sed 's|/|%2F|g')")
echo "DELETE branch $$BRANCH -> HTTP $$code (best effort)"
+6 -7
View File
@@ -1,5 +1,5 @@
---
# Deploy a NetBird reverse-proxy HA cluster member (a second proxy host).
# Deploy the NetBird reverse-proxy HA cluster members (the public edge).
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.yml
#
# Prerequisites:
@@ -7,12 +7,11 @@
# listed under [netbird_proxy] in inventory/pve_hosts.yml. Docker is
# installed by the (Arch-aware) docker-install role.
# - DNS A/AAAA for the host's own name (e.g. proxy-1.renner.uno) -> its IP.
# - Uses the existing vault_netbird_proxy_token (shared with VPS-1) — no new
# token to generate; a shared token + same NB_PROXY_DOMAIN forms the cluster.
# - AFTER deploy + verify: add this host's IP to the proxy domain so traffic
# round-robins across the cluster. Repoint proxy.renner.uno away from its
# CNAME to netbird.renner.uno (management host) to A/AAAA records listing
# BOTH proxy IPs (VPS-1 + this host).
# - Uses the existing vault_netbird_proxy_token (shared by every member) — no
# new token to generate; a shared token + same NB_PROXY_DOMAIN forms the
# cluster.
# - AFTER deploy + verify: add this host's A/AAAA to proxy.renner.uno so
# traffic round-robins across the cluster.
- name: Deploy NetBird reverse-proxy cluster member
hosts: netbird_proxy
-1
View File
@@ -18,4 +18,3 @@
- common
- docker-install
- netbird-server
- netbird-proxy-watchdog
+4 -4
View File
@@ -43,10 +43,10 @@
#
# WHAT IS NOT, and must not be added:
#
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on both
# the server and the proxies, all Ansible
# templates — but the management server does not
# validate it from any of them. It is registered
# vault_netbird_proxy_token LOOKS symmetric — it is in proxy.env on the
# proxies, an Ansible template — but the
# management server does not validate it from
# any file. It is registered
# in the management DATABASE. Rotating it changes
# only what the proxies present, so every proxy
# is locked out: "proxy auth failed: too many
+12 -1
View File
@@ -26,8 +26,16 @@ edge_open3e_poll_seconds: 30
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) --
# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor
# thermostats. Per-device room temperature, active setpoint, humidity, valve
# opening/demand, battery and RSSI; the local replacement for the ViCare cloud
# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock
# codec declares 57 bytes and a paired slot answers with 68.
# Only CurrentValues are polled. The room and device registries (1884.., 2084..)
# are static configuration and are read on demand, not every 30 s.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
@@ -38,6 +46,9 @@ edge_open3e_devices:
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
vcmu: { tx: "0x68c" }
# One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at
# their real 68-byte length (see files/open3e-onebase.py).
onebase: { tx: "0x6c3", dpList: "/config/onebase.py" }
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
@@ -0,0 +1,54 @@
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
#
# Upstream declares ZigBee<N>DeviceCurrentValues as 57 bytes. A PAIRED device
# slot on this gateway answers with 68: the 57 known bytes plus the parent
# device serial and three trailing bytes. udsoncan reads only the declared
# length and then treats the next two bytes -- the first half of that serial --
# as a foreign data identifier, so every paired slot fails to decode with
# "Server returned values for data identifier 0x.... that was not requested".
# The bogus identifier differs per device because the serial does.
#
# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's
# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor
# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather
# than copied.
#
# EMPTY slots answer with 57 and decode against the stock codec, so they are
# left alone and are not polled. A slot that is later unpaired therefore starts
# reporting a decode error on its topic instead of a value.
#
# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes.
from open3e.Open3Ecodecs import O3EComplexType, RawCodec
from open3e.Open3Edatapoints import dataIdentifiers as _master
# ZigBee<N>DeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143
# in steps of 3, devices 21-29 continue at 2262..2286.
_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [
2262 + 3 * n for n in range(9)
]
def _with_parent_serial(did):
stock = _master["dids"][did]
return O3EComplexType(
68,
stock.id,
list(stock.subTypes)
+ [
RawCodec(8, "ParentDeviceSerialnumber"),
RawCodec(2, "Unknown_65_66"),
RawCodec(1, "Unknown_67"),
],
desc=stock.desc,
info=stock.info,
acc=stock.acc,
)
dataIdentifiers = {
"dids": {
**_master["dids"],
**{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES},
}
}
+7
View File
@@ -129,6 +129,13 @@
mode: "0644"
notify: Restart heating-can
- name: Deploy open3e One Base datapoint overlay (68-byte ZigBee slots)
ansible.builtin.copy:
src: open3e-onebase.py
dest: "{{ heating_can_base_dir }}/onebase.py"
mode: "0644"
notify: Restart heating-can
# --- Compose project ---------------------------------------------------------
- name: Deploy the broker credential env file
ansible.builtin.template:
@@ -17,6 +17,7 @@ services:
volumes:
- ./devices.json:/config/devices.json:ro
- ./vx3plus.py:/config/vx3plus.py:ro
- ./onebase.py:/config/onebase.py:ro
env_file:
# Broker credential, 0600 -- see heating-can.env.j2.
- heating-can.env
@@ -1,5 +1,5 @@
---
# Self-healing watchdog for the NetBird reverse proxy (VPS-1 and proxy-1).
# Self-healing watchdog for the NetBird reverse proxy on the proxy hosts.
#
# Why it exists: the proxy's embedded NetBird client pins each proxied
# domain's route to a specific routing-peer identity. The netbird-ingress
@@ -20,9 +20,7 @@ netbird_watchdog_probe_hosts:
- home.renner.uno
- immich.renner.uno
# Address the probe connects to. 127.0.0.1 reaches the local proxy on both
# members: VPS-1 routes it via Traefik's HostSNI(*) TCP passthrough, proxy-1
# binds :443 directly.
# Address the probe connects to. The proxy binds :443 on the host directly.
netbird_watchdog_probe_address: 127.0.0.1
netbird_watchdog_container: netbird-proxy
@@ -1,7 +1,7 @@
---
# Deploys the reverse-proxy watchdog (see defaults/main.yml for why).
- name: Ensure curl is installed (probe dependency; proxy-1 is Arch, VPS-1 Debian)
- name: Ensure curl is installed (probe dependency)
ansible.builtin.package:
name: curl
state: present
@@ -1,8 +1,8 @@
---
# NetBird reverse-proxy HA cluster member (proxy-only host, e.g. proxy-1).
# Joins the same proxy cluster as the VPS-1 proxy: identical NB_PROXY_DOMAIN
# + the same token. NetBird load-balances/fails the cluster over via DNS
# round-robin on the proxy domain (see plays/install-netbird-proxy.yml).
# Every member uses the same NB_PROXY_DOMAIN + token. NetBird load-balances/
# fails the cluster over via DNS round-robin on the proxy domain (see
# plays/install-netbird-proxy.yml).
# MUST match the netbird-server role's netbird_version — the reverse-proxy
# release line has to track the management server it enrolls against.
@@ -11,11 +11,13 @@ netbird_version: "0.73.2"
# Management server this proxy enrolls against (public API, TLS).
netbird_server_host: netbird.renner.uno
# Proxy domain — MUST be identical across every cluster member.
# Proxy domain — MUST be identical across every cluster member, and must NOT
# be a subdomain of the management domain (netbird.renner.uno) per NetBird
# docs (TLS/routing conflicts).
netbird_proxy_domain: "proxy.renner.uno"
# Reuse VPS-1's proxy token — a shared NB_PROXY_TOKEN across members with the
# same NB_PROXY_DOMAIN is a valid HA cluster, so no separate token is needed.
# A shared NB_PROXY_TOKEN across members with the same NB_PROXY_DOMAIN is a
# valid HA cluster, so one token serves every member.
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
# Relay auth secret — MUST be identical across every relay instance and the
@@ -1,8 +1,7 @@
# NetBird reverse-proxy — HA cluster member on a proxy-only host.
# Just the reverse-proxy container, bound directly to :443. No Traefik:
# unlike VPS-1 (where the proxy shares :443 with management/signal/relay via
# SNI routing), this host runs nothing else, so the proxy owns the port and
# terminates TLS + answers its own tls-alpn-01 ACME challenges directly.
# Just the reverse-proxy container, bound directly to :443. No Traefik: the
# proxy owns the port and terminates TLS + answers its own tls-alpn-01 ACME
# challenges directly.
services:
proxy:
image: netbirdio/reverse-proxy:{{ netbird_version }}
@@ -23,9 +23,3 @@ netbird_datastore_key: "{{ vault_netbird_datastore_key }}"
# Relay secret for WebSocket relay authentication
netbird_relay_secret: "{{ vault_netbird_relay_secret }}"
# Reverse proxy configuration
# IMPORTANT: Proxy domain must NOT be a subdomain of the management domain
# (netbird.renner.uno) per NetBird docs - causes TLS/routing conflicts
netbird_proxy_domain: "proxy.renner.uno"
netbird_proxy_token: "{{ vault_netbird_proxy_token }}"
@@ -16,7 +16,3 @@
- name: Restart netbird coturn
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d --force-recreate coturn
- name: Restart netbird proxy
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d --force-recreate proxy
+68 -9
View File
@@ -36,7 +36,6 @@
- /opt/netbird/management
- /opt/netbird/signal
- /opt/netbird/letsencrypt
- /opt/netbird/proxy/certs
- name: Deploy docker-compose.yml
ansible.builtin.template:
@@ -59,23 +58,83 @@
mode: "0644"
notify: Restart netbird coturn
- name: Deploy proxy.env
ansible.builtin.template:
src: proxy.env.j2
dest: /opt/netbird/proxy.env
mode: "0644"
notify: Restart netbird proxy
# The public edge is the proxy hosts (plays/install-netbird-proxy.yml); this
# host runs no reverse proxy. The tasks below remove the one it used to run,
# its watchdogs first so they cannot restart it mid-removal, and can go once
# the role has run against every host that ever carried it.
- name: Check for reverse-proxy watchdog units
ansible.builtin.stat:
path: "/etc/systemd/system/{{ item }}"
loop:
- netbird-proxy-watchdog.timer
- netbird-proxy-flood-watchdog.service
register: _proxy_watchdog_units
- name: Stop and disable reverse-proxy watchdogs
ansible.builtin.systemd:
name: "{{ item.item }}"
state: stopped
enabled: false
loop: "{{ _proxy_watchdog_units.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
- name: Remove reverse-proxy watchdog units and scripts
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/system/netbird-proxy-watchdog.timer
- /etc/systemd/system/netbird-proxy-watchdog.service
- /etc/systemd/system/netbird-proxy-flood-watchdog.service
- /usr/local/bin/netbird-proxy-watchdog
- /usr/local/bin/netbird-proxy-flood-watchdog
- /run/netbird-proxy-watchdog
register: _proxy_watchdog_files
- name: Reload systemd after removing the watchdog units
ansible.builtin.systemd:
daemon_reload: true
when: _proxy_watchdog_files.changed
- name: Pull NetBird container images
ansible.builtin.shell: |
cd /opt/netbird && docker compose pull
changed_when: false
# --remove-orphans removes containers of services no longer in the compose
# file (the reverse proxy).
- name: Start NetBird containers
ansible.builtin.shell: |
cd /opt/netbird && docker compose up -d
cd /opt/netbird && docker compose up -d --remove-orphans
register: docker_compose_result
changed_when: "'Started' in docker_compose_result.stdout or 'Creating' in docker_compose_result.stdout"
changed_when: >-
'Started' in docker_compose_result.stdout or
'Creating' in docker_compose_result.stdout or
'Removed' in docker_compose_result.stdout or
'Removed' in docker_compose_result.stderr
- name: Remove reverse-proxy volumes
ansible.builtin.shell: |
set -eu
for v in netbird_netbird_proxy_certs netbird_netbird_proxy_data netbird_netbird_proxy_state; do
if [ -n "$(docker volume ls -q --filter "name=^${v}$")" ]; then
docker volume rm "$v"
echo "removed $v"
fi
done
register: _proxy_volumes
changed_when: "'removed' in _proxy_volumes.stdout"
- name: Remove reverse-proxy config and state
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /opt/netbird/proxy.env
- /opt/netbird/proxy
- /opt/netbird/proxy-state
# The management store has no cluster-side backup. Pulling one put a root
# key on this host that the cluster held, and this server's own state is
@@ -187,10 +187,9 @@ services:
- "traefik.http.routers.netbird-management.service=netbird-management"
- "traefik.http.services.netbird-management.loadbalancer.server.port=33073"
- "traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c"
# gRPC reverse-proxy service — remote reverse-proxy instances (e.g.
# proxy-1) connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443.
# The local proxy uses management:33073 directly, so it never needed
# this route; remote ones get a 404 without it. Same backend (h2c 33073).
# gRPC reverse-proxy service — the proxy hosts (proxy-1, proxy-2)
# connect here via NB_PROXY_MANAGEMENT_ADDRESS=https://host:443 and get
# a 404 without it. Same backend (h2c 33073).
- "traefik.http.routers.netbird-proxyservice.rule=Host(`{{ netbird_server_host }}`) && PathPrefix(`/management.ProxyService/`)"
- "traefik.http.routers.netbird-proxyservice.entrypoints=websecure"
- "traefik.http.routers.netbird-proxyservice.tls=true"
@@ -221,37 +220,6 @@ services:
max-size: "500m"
max-file: "2"
# Reverse Proxy Service (per docs.netbird.io/selfhosted/migration/enable-reverse-proxy)
proxy:
image: netbirdio/reverse-proxy:{{ netbird_version }}
container_name: netbird-proxy
restart: unless-stopped
networks:
- netbird
depends_on:
- management
env_file:
- ./proxy.env
volumes:
- netbird_proxy_certs:/certs
- /opt/netbird/proxy-state:/var/lib/netbird
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.proxy-passthrough.entrypoints=websecure"
- "traefik.tcp.routers.proxy-passthrough.rule=HostSNI(`*`)"
- "traefik.tcp.routers.proxy-passthrough.tls.passthrough=true"
- "traefik.tcp.routers.proxy-passthrough.service=proxy-tls"
- "traefik.tcp.routers.proxy-passthrough.priority=1"
- "traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443"
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
networks:
netbird:
driver: bridge
volumes:
netbird_proxy_certs:
@@ -1,8 +0,0 @@
NB_PROXY_DOMAIN={{ netbird_proxy_domain }}
NB_PROXY_TOKEN={{ netbird_proxy_token }}
NB_PROXY_MANAGEMENT_ADDRESS=http://management:33073
NB_PROXY_ALLOW_INSECURE=true
NB_PROXY_ADDRESS=:8443
NB_PROXY_ACME_CERTIFICATES=true
NB_PROXY_ACME_CHALLENGE_TYPE=tls-alpn-01
NB_PROXY_CERTIFICATE_DIRECTORY=/certs
@@ -0,0 +1,120 @@
{
"//": "Permission scope for the wiki-consolidate agent in CI: read the vault and the",
"//2": "source repos cloned under /root/source, read the live cluster through the",
"//3": "ci-diagnostics kubeconfig, edit wiki/, output/ and the rotation state only.",
"//4": "Deny rules win; git and kubectl are read-only subcommands, no network tools.",
"permissions": {
"allow": [
"Read(//root/Notes/**)",
"Read(//root/source/**)",
"Edit(//root/Notes/wiki/**)",
"Edit(//root/Notes/output/**)",
"Edit(//root/Notes/.wiki-consolidation-state.json)",
"Bash(/root/Notes/bin/wiki-snapshot.sh:*)",
"Bash(/root/Notes/bin/wiki_rank.py:*)",
"Bash(/root/Notes/bin/wiki_ask.py:*)",
"Bash(~/Notes/bin/wiki-snapshot.sh:*)",
"Bash(~/Notes/bin/wiki_rank.py:*)",
"Bash(~/Notes/bin/wiki_ask.py:*)",
"Bash(~/Notes/bin/wiki-delete.sh:*)",
"Bash(git -C /root/Notes log:*)",
"Bash(git -C /root/Notes show:*)",
"Bash(git -C /root/Notes diff:*)",
"Bash(git -C /root/Notes status:*)",
"Bash(git -C /root/Notes rev-parse:*)",
"Bash(git -C /root/Notes ls-files:*)",
"Bash(git -C ~/Notes log:*)",
"Bash(git -C ~/Notes show:*)",
"Bash(git -C ~/Notes diff:*)",
"Bash(git -C ~/Notes status:*)",
"Bash(git -C ~/Notes rev-parse:*)",
"Bash(git -C ~/Notes ls-files:*)",
"Bash(kubectl get:*)",
"Bash(kubectl describe:*)",
"Bash(kubectl version:*)",
"Bash(kubectl api-resources:*)",
"Bash(kubectl explain:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git rev-parse:*)",
"Bash(git ls-files:*)",
"Bash(git -C /root/source/homelab log:*)",
"Bash(git -C /root/source/homelab show:*)",
"Bash(git -C /root/source/homelab diff:*)",
"Bash(git -C /root/source/homelab status:*)",
"Bash(git -C /root/source/homelab rev-parse:*)",
"Bash(git -C /root/source/homelab ls-files:*)",
"Bash(git -C /root/source/homeassistant log:*)",
"Bash(git -C /root/source/homeassistant show:*)",
"Bash(git -C /root/source/homeassistant diff:*)",
"Bash(git -C /root/source/homeassistant status:*)",
"Bash(git -C /root/source/homeassistant rev-parse:*)",
"Bash(git -C /root/source/homeassistant ls-files:*)",
"Bash(git -C ~/source/homelab log:*)",
"Bash(git -C ~/source/homelab show:*)",
"Bash(git -C ~/source/homelab diff:*)",
"Bash(git -C ~/source/homelab status:*)",
"Bash(git -C ~/source/homelab rev-parse:*)",
"Bash(git -C ~/source/homelab ls-files:*)",
"Bash(git -C ~/source/homeassistant log:*)",
"Bash(git -C ~/source/homeassistant show:*)",
"Bash(git -C ~/source/homeassistant diff:*)",
"Bash(git -C ~/source/homeassistant status:*)",
"Bash(git -C ~/source/homeassistant rev-parse:*)",
"Bash(git -C ~/source/homeassistant ls-files:*)",
"Bash(/root/Notes/bin/wiki-delete.sh:*)",
"Bash(ls:*)",
"Bash(date:*)",
"Bash(wc:*)",
"Bash(stat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": [
"Read(//proc/**)",
"Read(//etc/**)",
"Read(//root/.config/**)",
"Read(//root/.kube/**)",
"Read(//woodpecker/**/.ci-diag/**)",
"Read(//root/Notes/.git/**)",
"Read(//root/source/*/.git/**)",
"Read(//root/Notes/_archive/**)",
"Edit(//root/Notes/raw/**)",
"Edit(//root/Notes/_archive/**)",
"Edit(//root/source/**)",
"Bash(kubectl apply:*)",
"Bash(kubectl delete:*)",
"Bash(kubectl patch:*)",
"Bash(kubectl edit:*)",
"Bash(kubectl scale:*)",
"Bash(kubectl rollout:*)",
"Bash(kubectl exec:*)",
"Bash(kubectl cp:*)",
"Bash(kubectl drain:*)",
"Bash(kubectl cordon:*)",
"Bash(kubectl label:*)",
"Bash(kubectl annotate:*)",
"Bash(git push:*)",
"Bash(git reset:*)",
"Bash(git checkout:*)",
"Bash(git clean:*)",
"Bash(git rebase:*)",
"Bash(git config:*)",
"Bash(git -C /root/Notes config:*)",
"Bash(git -C ~/Notes config:*)",
"Bash(git -C /root/source/homelab config:*)",
"Bash(git -C /root/source/homeassistant config:*)",
"Bash(git -C ~/source/homelab config:*)",
"Bash(git -C ~/source/homeassistant config:*)",
"Bash(rm:*)",
"Bash(rm -rf:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(ssh:*)",
"Bash(tea:*)",
"Bash(woodpecker:*)"
]
}
}
@@ -0,0 +1,5 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes); the homelab and homeassistant repositories are checked out under ~/source/. `kubectl` reads the live cluster with a read-only identity.
Run the wiki-consolidate agent now for today's scheduled consolidation pass. Follow its instructions exactly: snapshot the vault to git first, abort if that fails, select today's rotating cohort of 20 from the state file, verify claims against both their sources and the live systems, act on the verdicts, write the dated report to ~/Notes/output/, update the state file, and snapshot again at the end. Do not push; the pipeline pushes the run's commits.
Report the counts and the coverage progress when you are done.
+7
View File
@@ -0,0 +1,7 @@
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes). Do exactly this, nothing else:
Glob ~/Notes/raw/*.md (top level only, never ~/Notes/raw/_processed/). Distill every file you find into ~/Notes/wiki/ following your process, including the ranker check for every concept. Move each fully processed source to ~/Notes/raw/_processed/. If raw/ has no top-level files besides _index.md, say so and stop.
Do not commit or push; the pipeline does that after you finish. Do not touch anything outside ~/Notes/wiki/ and ~/Notes/raw/.
End with one line per file: which wiki notes it produced or merged into, or "nothing distillable".
+41
View File
@@ -0,0 +1,41 @@
{
"//": "Permission scope for the wiki-maintenance agent (wiki-distill) in CI.",
"//2": "The step's environment holds a Gitea push token, a TypeSafe key and the wiki-index",
"//3": "client credential, so the Read tool is kept away from /proc (env dumps), /root outside",
"//4": "the vault symlink target, /etc and the checkout's .git/. The vault itself is the work.",
"//5": "Mirrors ci/agents/issue-fix/settings.json; absolute globs because the vault is reached",
"//6": "through the ~/Notes symlink the pipeline creates.",
"permissions": {
"allow": [
"Read(//root/Notes/**)",
"Edit(//root/Notes/wiki/**)",
"Edit(//root/Notes/output/**)",
"Edit(//root/Notes/raw/**)",
"Bash(/root/Notes/bin/wiki_rank.py:*)",
"Bash(/root/Notes/bin/wiki_ask.py:*)",
"Bash(mv /root/Notes/raw/:*)",
"Bash(mkdir -p /root/Notes/:*)",
"Bash(~/Notes/bin/wiki_rank.py:*)",
"Bash(~/Notes/bin/wiki_ask.py:*)",
"Bash(mv ~/Notes/raw/:*)",
"Bash(mkdir -p ~/Notes/:*)",
"Bash(ls:*)",
"Bash(date:*)",
"Bash(wc:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": [
"Read(//proc/**)",
"Read(//etc/**)",
"Read(//root/.config/**)",
"Read(//root/Notes/.git/**)",
"Read(//root/Notes/_archive/**)",
"Edit(//root/Notes/_archive/**)",
"Bash(rm:*)",
"Bash(git:*)",
"Bash(curl:*)",
"Bash(wget:*)"
]
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
#
# 3.22 rather than the 3.21 the other images here pin: bird2 first appears in
# 3.22/community.
#
# Config is read from /etc/bird/bird.conf, not the package default
# /etc/bird.conf, so the mount cannot shadow what the package ships.
#
# kubectl renders the init container's per-node config: go-template-file for
# the templating, a field selector for excluding the node itself, in-cluster
# config for the auth. From the distro, so there is no version literal to drift
# and no download to trust. It is only ever used to read core/v1 Nodes.
RUN apk add --no-cache bird2 kubectl
# Fails the build rather than the first pod if a binary or its path is wrong.
RUN /usr/sbin/bird --version \
&& kubectl version --client > /dev/null
ENTRYPOINT ["/usr/sbin/bird", "-f", "-c", "/etc/bird/bird.conf"]
+3 -1
View File
@@ -13,7 +13,9 @@ RUN apk add --no-cache \
git \
jq \
openssh-client \
openssl
openssl \
python3 \
py3-yaml
# Install Claude Code, then make the resulting layer byte-stable so that a
# rebuild of the SAME version DEDUPS in the Gitea registry instead of orphaning
+5 -5
View File
@@ -1,9 +1,9 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
ARG TALOS_VERSION=v1.12.6
ARG TALOS_VERSION=v1.13.10
ARG VELERO_VERSION=v1.16.0
ARG KUBECTL_VERSION=v1.36.2
ARG FLUX_VERSION=2.9.2
ARG KUBECTL_VERSION=v1.36.3
ARG FLUX_VERSION=2.9.5
RUN apk add --no-cache \
aws-cli \
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "4c41a3b10b075292e64b182283d558e9d8e935f43239de1ed4e4b14359593efb /usr/local/bin/talosctl" | sha256sum -c - \
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
@@ -31,7 +31,7 @@ RUN curl -sfL -o /usr/local/bin/kubectl \
RUN curl -sfL -o /tmp/flux.tar.gz \
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
&& echo "b853df82adfd7736f580692f9f734473d571606307139f8fd20c2a80dd1ff473 /tmp/flux.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
&& chmod +x /usr/local/bin/flux \
&& rm -f /tmp/flux.tar.gz
@@ -1,4 +1,4 @@
# netbird-proxy-services: reconciles NetBird reverse-proxy services (public
# netbird-service-sync: reconciles NetBird reverse-proxy services (public
# 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.
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
"""netbird-proxy-services: reconcile NetBird reverse-proxy services from Ingresses.
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
For each host on such an Ingress the NetBird management gets one reverse-proxy
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
subnet resource that covers the cluster's service CIDR. SNI and Host reach
Traefik untouched, the proxy terminates nothing, and no domain resource is
involved -- the routing peers stay ordinary IP routers.
involved -- the routing peers stay ordinary IP routers. The target sends a
PROXY protocol v2 header so Traefik (and CrowdSec behind its access log) sees
the WAN client address instead of the routing peer's pod IP.
Ownership is derived from NetBird itself, not from local state: a service is
managed iff its mode is "tls" and its target is this controller's subnet
@@ -28,11 +30,11 @@ import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger("netbird-proxy-services")
LOG = logging.getLogger("netbird-service-sync")
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-proxy-services")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-service-sync")
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
@@ -154,7 +156,7 @@ def ensure_network(nb):
if n["name"] == NETWORK_NAME:
return n["id"]
n = nb.post("networks", {"name": NETWORK_NAME,
"description": "managed by netbird-proxy-services; public ingress via TLS passthrough"})
"description": "managed by netbird-service-sync; public ingress via TLS passthrough"})
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
return n["id"]
@@ -164,7 +166,7 @@ def ensure_resource(nb, net):
if r.get("address") == SERVICE_CIDR:
return r["id"]
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
"description": "managed by netbird-proxy-services",
"description": "managed by netbird-service-sync",
"enabled": True, "groups": []})
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
return r["id"]
@@ -194,9 +196,12 @@ def ensure_policy(nb, resource, source_group):
def service_body(host, resource, target_ip):
# proxy_protocol: PROXY v2 header carries the WAN client address to
# Traefik (proxyProtocol.trustedIPs on websecure); TCP/TLS modes only.
return {"name": host, "domain": host, "mode": "tls", "listen_port": LISTEN_PORT, "enabled": True,
"targets": [{"target_type": "subnet", "target_id": resource, "protocol": "tcp",
"host": target_ip, "port": 443, "enabled": True}]}
"host": target_ip, "port": 443, "enabled": True,
"options": {"proxy_protocol": True}}]}
def is_managed(svc, resource):
@@ -208,7 +213,8 @@ def target_matches(svc, target_ip):
return False
ts = svc.get("targets") or []
return len(ts) == 1 and ts[0].get("host") == target_ip and ts[0].get("port") == 443 \
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled")
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled") \
and (ts[0].get("options") or {}).get("proxy_protocol") is True
def reconcile():
+2 -2
View File
@@ -14,7 +14,7 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650
# kubernetes/talos/patches/cluster.yaml. The phase reads the version from that
# file at run time and passes it to the provisioner; this pin only decides which
# client talks to it.
ARG TALOS_VERSION=v1.13.9
ARG TALOS_VERSION=v1.13.10
ARG KUBECTL_VERSION=v1.36.2
ARG FLUX_VERSION=2.9.2
ARG KOPIA_VERSION=0.23.1
@@ -49,7 +49,7 @@ RUN apk add --no-cache \
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
@@ -0,0 +1,976 @@
# Paperclip Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets.
**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree.
**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints.
**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md`
## Global Constraints
- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout.
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge.
- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`.
- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label).
- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`.
- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`.
- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`.
- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`.
- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`.
- Manifest comments: one terse factual line; rationale goes in commit messages/PR.
- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed.
## Review Focus
1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6.
2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7.
3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4.
4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3.
5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5.
---
### Task 1: Upstream facts that decide two manifest values
No repo changes; produces two decisions consumed by Task 6.
**Interfaces:**
- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no).
- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`):
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"`
Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed.
- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`).
Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"`
Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields.
- [ ] **Step 3: Entrypoint privilege.**
Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"`
`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6.
- [ ] **Step 4: Anonymous health response.**
Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"`
Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead.
---
### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist
**Files:**
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append)
- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml`
- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list)
**Interfaces:**
- Produces: namespace `paperclip`; ESO may reference `openbao` from it.
- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`:
```yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: paperclip
```
- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped):
```yaml
# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: paperclip
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": rook-ceph
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- toEntities:
- host
- remote-node
- world
- toEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
```
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`.
- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted).
- [ ] **Step 5: Render check.**
Run: `kubectl kustomize kubernetes/infrastructure/network-policies`
Expected: output contains `namespace: paperclip`, exit 0.
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml`
Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`.
- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`.
---
### Task 3: OpenBao secret `apps/paperclip`
Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready.
**Interfaces:**
- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints.
- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/<name>` key. Use exactly what it returns.
- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command:
`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format).
Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them.
- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch <mount>/apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=<token>`." Wait for confirmation.
- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get <mount>/apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist.
---
### Task 4: Image automation + initial digest
**Files:**
- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml`
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml`
**Interfaces:**
- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6.
- [ ] **Step 1: Create `paperclip.yaml`:**
```yaml
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: paperclip
namespace: flux-system
spec:
image: ghcr.io/paperclipai/paperclip
interval: 1h
---
# Upstream publishes no semver tags; track the nightly tag by digest.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: paperclip
namespace: flux-system
spec:
imageRepositoryRef:
name: paperclip
digestReflectionPolicy: Always
filterTags:
pattern: '^nightly$'
policy:
alphabetical:
order: asc
```
- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`.
- [ ] **Step 3: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml`
Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error.
- [ ] **Step 4: Resolve the current nightly digest.**
The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once:
Run: `docker pull ghcr.io/paperclipai/paperclip:nightly`
Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly`
Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`.
Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly`
- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`.
---
### Task 5: Postgres (CNPG) and secrets in the app directory
**Files:**
- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml`
**Interfaces:**
- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`.
- [ ] **Step 1: `cluster.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pclp-pg
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
# PG17: upstream's external Postgres target (docker/docker-compose.yml).
imageName: ghcr.io/cloudnative-pg/postgresql:17
storage:
storageClass: ceph-block
size: 10Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: pclp-pg-store
bootstrap:
initdb:
database: paperclip
owner: paperclip
postgresql:
parameters:
shared_buffers: "256MB"
max_connections: "200"
checkpoint_timeout: "15min"
max_wal_size: "2GB"
wal_compression: "zstd"
archive_timeout: "30min"
synchronous:
method: any
number: 1
dataDurability: preferred
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
```
- [ ] **Step 2: `objectstore.yaml`:**
```yaml
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: pclp-pg-store
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup target
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
```
- [ ] **Step 3: `scheduled-backup.yaml`:**
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pclp-pg-daily
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule
spec:
schedule: "0 45 2 * * *"
immediate: true
backupOwnerReference: self
cluster:
name: pclp-pg
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
```
- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
```
- [ ] **Step 5: `externalsecret.yaml`:**
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: paperclip-secrets
namespace: paperclip
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: paperclip-secrets
dataFrom:
- extract:
key: apps/paperclip
```
- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.)
---
### Task 6: Server workload, storage, ingress, network policy
**Files:**
- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml`
**Interfaces:**
- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1).
- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`.
- [ ] **Step 1: `pvc.yaml`:**
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperclip-data
namespace: paperclip
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled # data guard
spec:
accessModes: [ReadWriteOnce]
storageClassName: ceph-block
resources:
requests:
storage: 20Gi
```
- [ ] **Step 2: `paperclip.yaml`** — replace `<INITIAL_DIGEST>` with the Task 4 value and `<SIGNUP_DISABLED>` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperclip
namespace: paperclip
labels:
app.kubernetes.io/name: paperclip
annotations:
secret.reloader.stakater.com/reload: paperclip-secrets
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperclip
template:
metadata:
labels:
app.kubernetes.io/name: paperclip
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
# Telemetry is only configurable in config.json.
- name: disable-telemetry
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
command:
- sh
- -c
- |
set -eu
f=/paperclip/instances/default/config.json
mkdir -p "$(dirname "$f")"
if [ -s "$f" ]; then
jq '.telemetry.enabled = false' "$f" > "$f.tmp"
else
printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp"
fi
mv "$f.tmp" "$f"
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /paperclip
containers:
- name: paperclip
image: ghcr.io/paperclipai/paperclip:nightly@<INITIAL_DIGEST> # {"$imagepolicy": "flux-system:paperclip"}
ports:
- name: http
containerPort: 3100
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: pclp-pg-app
key: uri
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "3100"
- name: PAPERCLIP_DEPLOYMENT_MODE
value: authenticated
- name: PAPERCLIP_DEPLOYMENT_EXPOSURE
value: private
- name: PAPERCLIP_PUBLIC_URL
value: https://paperclip.renner.uno
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
value: "<SIGNUP_DISABLED>"
envFrom:
- secretRef:
name: paperclip-secrets
volumeMounts:
- name: data
mountPath: /paperclip
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 60
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
volumes:
- name: data
persistentVolumeClaim:
claimName: paperclip-data
---
apiVersion: v1
kind: Service
metadata:
name: paperclip
namespace: paperclip
spec:
selector:
app.kubernetes.io/name: paperclip
ports:
- name: http
port: 3100
targetPort: http
```
- [ ] **Step 3: `gate-service.yaml`:**
```yaml
# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-paperclip-gate
namespace: paperclip
spec:
type: ExternalName
externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
```
- [ ] **Step 4: `ingress.yaml`:**
```yaml
# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperclip
namespace: paperclip
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- paperclip.renner.uno
secretName: paperclip-tls
rules:
- host: paperclip.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-paperclip-gate
port:
number: 9000
```
- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`):
```yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-ingress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: paperclip-gate
toPorts:
- ports:
- port: "3100"
protocol: TCP
---
# Covers the server and every agent it spawns (same pod).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-egress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paperclip
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
cnpg.io/cluster: pclp-pg
toPorts:
- ports:
- port: "5432"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Gitea, Woodpecker, HA, Loki via their *.renner.uno names.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
```
- [ ] **Step 6: `kustomization.yaml`:**
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- pvc.yaml
- paperclip.yaml
- gate-service.yaml
- ingress.yaml
- network-policy.yaml
```
- [ ] **Step 7: Render check.**
Run: `kubectl kustomize kubernetes/apps/paperclip`
Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `<INITIAL_DIGEST>` or `<SIGNUP_DISABLED>`.
Run: `grep -rn "<INITIAL_DIGEST>\|<SIGNUP_DISABLED>" kubernetes/apps/paperclip`
Expected: no output, exit 1.
- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`.
---
### Task 7: Authentik gate
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`)
- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`.
- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block):
```yaml
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
- id: paperclip-users-group
model: authentik_core.group
identifiers:
name: Paperclip Users
state: present
attrs:
is_superuser: false
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: paperclip-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperclip-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
internal_host_ssl_validation: false
external_host: https://paperclip.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: paperclip-gate
state: present
attrs:
name: Paperclip
slug: paperclip-gate
provider: !KeyOf paperclip-gate-provider
meta_launch_url: https://paperclip.renner.uno
meta_description: "Paperclip agent orchestration"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperclip-gate]]
group: !KeyOf paperclip-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
- model: authentik_outposts.outpost
identifiers:
name: paperclip-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf paperclip-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
```
- [ ] **Step 2: `paperclip-gate-egress.yaml`:**
```yaml
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
```
- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`.
- [ ] **Step 4: Render + dry-run.**
Run: `kubectl kustomize kubernetes/apps/authentik`
Expected: exit 0 (valid YAML incl. the blueprint ConfigMap).
Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml`
Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`.
- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`.
---
### Task 8: Flux Kustomization
**Files:**
- Create: `kubernetes/clusters/homelab/paperclip.yaml`
- [ ] **Step 1:**
```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: paperclip
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 20m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/paperclip
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
- name: infrastructure-cnpg-barman
healthCheckExprs:
- apiVersion: postgresql.cnpg.io/v1
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
```
- [ ] **Step 2: Server dry-run.**
Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml`
Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`.
- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`.
---
### Task 9: PR, merge by user, live verification
- [ ] **Step 1: Push and open the PR** (Task 3 must be complete).
Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip`
Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "<summary of spec decisions + link to spec + bootstrap steps>\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"`
Then wait for the user to merge. Do not merge.
- [ ] **Step 2: Reconciliation.** After merge:
Run: `flux get kustomizations paperclip`
Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke.
Run: `kubectl get cluster -n paperclip pclp-pg`
Expected: `Cluster in healthy state`, 2 ready instances.
Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip`
Expected: `1/1 Running`.
- [ ] **Step 3: Gate enforced (Review Focus 4).**
Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/`
Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`.
- [ ] **Step 4: Gate not bypassable (Review Focus 3).**
`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe.
Run: `kubectl get ciliumnetworkpolicies -n default`
Expected: `No resources found` (else pick another CNP-free namespace).
Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health`
Expected: timeout (exit 28), no HTTP response.
- [ ] **Step 5: First backup (Review Focus 5).**
Run: `kubectl get backup -n paperclip`
Expected: one `pclp-pg-daily-…` with phase `completed`.
- [ ] **Step 6: Image policy.**
Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'`
Expected: `nightly` tag with a `sha256:` digest.
- [ ] **Step 7: Hand-over to the user (browser steps, theirs):**
1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin.
2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang).
3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed.
4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI.
- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out.
@@ -0,0 +1,172 @@
# Paperclip in the homelab cluster — design
Date: 2026-09-25
Status: draft, awaiting review
## Goal
Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration
server + UI) in the homelab cluster for real use: its agents do actual work against
homelab systems. This spec covers the **platform** only. Bot identities and their
credentials are out of scope (see "Deferred").
## Decisions
| Topic | Decision |
|---|---|
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
| Claude auth | **One shared subscription OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN`, from `claude setup-token`) for all agents. |
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
### Known trade-off of B (accepted for the trial)
Upstream `runChildProcess()` spawns every local adapter with
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
`CLAUDE_CODE_OAUTH_TOKEN`, the shared filesystem (including every other agent's
workspace) and every agent-scoped secret it can read from disk. Under B, no bot may hold
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
## Architecture
```
LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns)
│ forward-auth OK
▼
paperclip Deployment (ns paperclip, 1 replica)
├─ server :3100 (+ agent CLIs as child processes)
├─ PVC /paperclip (ceph-block)
└─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3
```
## Components
All modeled on `kubernetes/apps/paperless/` unless stated.
### Namespace and baseline policy
- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`.
- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml`
(copy of `paperless-ns.yaml`, renamed).
- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist.
### Flux
- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`,
`path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and
CNPG `healthCheckExprs` as `paperless.yaml`.
- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list.
### Server Deployment (`paperclip.yaml`)
- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:<digest>` with the Flux
image-automation marker for digest updates.
- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC).
- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns
`/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN,
DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`.
- `automountServiceAccountToken: false` (all agents share the pod filesystem).
- Port 3100; `Service paperclip` ClusterIP :3100.
- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the
private-hostname guard rejects the pod IP); liveness is a TCP check, since
`/api/health` runs `SELECT 1` and a DB outage must not restart the server.
- Env:
- `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key).
- `HOST=0.0.0.0`, `PORT=3100`
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
- `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`
- `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
A follow-up flips it to `true`.
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
- `envFrom: secretRef paperclip-secrets`
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
### Storage
- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME`
and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime`
dirs.
### Postgres
- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`,
`ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`,
barman-cloud plugin as WAL archiver.
- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the
existing `hetzner-s3-creds` ExternalSecret pattern.
- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`.
- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup.
- PG17 deviates from the cluster-wide PG16 convention because upstream's external
Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default
is PG18.
### Secrets
One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object
extract):
- `BETTER_AUTH_SECRET`
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
encrypted agent secrets in Postgres survive PVC loss.
- `CLAUDE_CODE_OAUTH_TOKEN` (shared subscription token)
### Ingress and SSO
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only).
- Authentik gate, per the paperless pattern:
- blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`:
proxy provider `paperclip-gate` (`internal_host:
http://paperclip.paperclip.svc.cluster.local:3100`, `external_host:
https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding;
- outpost `ak-outpost-paperclip-gate`;
- `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno`
outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on
`paperclip.renner.uno`);
- `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress
targets it on :9000.
- Paperclip's own email/password login stays on (no header/OIDC login upstream).
### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`)
Covers the server **and every agent** (same pod under B).
- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100.
- Egress from the app pod:
- `pclp-pg` pods :5432
- kube-dns :53
- `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their
`*.renner.uno` names
- `kube-apiserver` entity :6443
- `world` TCP :443 (Anthropic API, GitHub, npm)
- No SSH egress; git over HTTPS only. The namespace baseline
(`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows
across CNPs, so an unrestricted baseline would override this list.
- Known gap (house baseline): pods in `monitoring`/`database` and other pods in
`paperclip` can reach :3100 without the gate; Paperclip's own login still applies.
### Image automation
`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository`
(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with
`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field
available since v0.35.0). First use of digest reflection in this repo.
## Guardrails
- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`.
- Git work goes through PRs; bots get no merge rights on protected branches.
- Nothing is granted under B that we would not tolerate any agent misusing.
## Bootstrap (one-time)
1. Place `apps/paperclip` values in OpenBao (generated secrets + shared OAuth token).
2. Merge; Flux deploys.
3. Claim the admin account via the `bootstrap-ceo` invite, add yourself to
"Paperclip Users" in Authentik.
## Verification
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
completes.
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
- Direct pod access from another namespace is denied (CNP).
- A test agent (claude-local) completes a trivial task using the shared OAuth token.
- Flux ImagePolicy reports a `nightly@sha256:` digest.
## Deferred
- **Option C** (sandboxed runs) — required before any per-bot credential isolation.
- **AIR-Manager** role and all bot identities/credentials.
- Public exposure.
+22 -16
View File
@@ -50,15 +50,18 @@ pass insert homelab/openbao/root-token
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
```
**Manual unseal is required after every pod restart** (node drain, upgrade,
crash), **per replica** — the server runs three raft peers (see
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
voter nor a standby, so it stays out of the cluster until unsealed:
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
all three. Three peers mean one sealed replica is survivable; two are not.
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
Secrets keep their last value (they are real k8s Secrets — pods keep running).
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
itself. The shamir key above became the **recovery key** in the migration; it
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
one is neither a voter nor a standby, so a replica that stays sealed after a
restart — wrong or missing seal-key Secret — costs redundancy, two cost
quorum. The trade-off taken with the static seal: the key that unlocks the
store sits in the same etcd as the Secrets ESO already syncs there in
plaintext. Symptom of sealed: ESO stores go NotReady, synced Secrets keep
their last value (they are real k8s Secrets — pods keep running).
## Phase 2 — configure + ClusterSecretStore (follow-up PR)
@@ -234,16 +237,19 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
A raft snapshot restore needs the matching unseal key — which lives in
A raft snapshot restore needs the matching seal key — which lives in
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
one tested restore before phase 4.
- **Cluster rebuild order:** Flux brings up openbao (sealed, empty) → restore
raft snapshot → unseal (key from `pass`) → ClusterSecretStore Ready → ESO
materializes Secrets → apps start. Until unseal, secret-consuming apps
simply stay Pending on missing Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds unseal key + root token (GPG, offline-capable).
With those two, everything is recoverable from a raft snapshot alone.
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
hand). Until then, secret-consuming apps simply stay Pending on missing
Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
offline-capable). With those, everything is recoverable from a raft
snapshot alone.
- **Ansible Vault password:** `ansible/vault-password.sh` reads
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
+99
View File
@@ -0,0 +1,99 @@
# Traffic classes on the k8s nodes
Issue #576. Five classes, each on its own VLAN.
## Where each class runs
| class | link | mechanism |
|---|---|---|
| Ceph replication | `ceph-san`, VLAN 49 | the whole link is handed to the OSD pod (Multus `host-device`) |
| pod↔pod | `pod0`, VLAN 48 | pod-sourced traffic is routed from table 47; bird puts the peers' pod CIDRs there via `pod0` |
| world↔pod | `ingress0`, VLAN 47 | BGP session sourced there (next hop of every LoadBalancer VIP); pod egress takes table 47's default |
| node↔node | `node0`, VLAN 46 | the node InternalIP and etcd live there; so do apiserver↔kubelet, cilium-health and host→remote-pod traffic |
| world↔node | `mgmt0`, VLAN 40 | API VIP for kubectl, talosctl from outside, node egress (DNS, NTP, image pulls) |
Hard constraint, set by the owner: inter-node and pod traffic never traverses
the UDM. Everything but world↔node stays on the 10G switch. VLAN 46 and 48 are
statically addressed, without a gateway; VLAN 46 is isolated in UniFi.
## Pod traffic: one table
Two rules, one per family, send every packet with a pod source to table 47
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
table decides by longest prefix; no rule carves out exceptions.
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
taken from the interfaces;
- the peers' pod CIDRs, learned over an iBGP mesh on `pod0`, next hop the
peer's `pod0` address;
- the node's own pod CIDR via `cilium_host`, originated rather than read back
from the kernel (reading it back made BIRD depend on the shape of Cilium's
route, and KRT rejects the IPv4 one);
- the egress default via the UDM on `ingress0`, from the node annotations
`net.renner.uno/egress-gw-v4/v6`.
`node0` has to be in the table: a pod reaching a node address — the
`kubernetes` ClusterIP, which socket LB rewrites to the apiserver's advertise
address, or a hostNetwork pod — would otherwise take the default to the UDM.
The routing decision runs before Cilium's masquerade hook, so the rule sees the
pod address and the masquerade then rewrites it to the egress link's.
Node-originated traffic does not match the rules and uses main, where Cilium
routes each peer's pod CIDR via the peer's InternalIP — on `node0`.
No address is written into this repo twice. Each node publishes its `pod0`
addresses as `nodeAnnotations` beside the `VLANConfig` that assigns them; the
pod CIDRs come from the Node objects. bird's init container renders both from
the API and ends with `bird -p`, so BIRD's own parser gates startup.
## Node↔node
`kubelet.nodeIP.validSubnets` and `cluster.etcd.advertisedSubnets` point at
VLAN 46. Talos picks one InternalIP per family, IPv4 first — the service CIDR
is IPv4, and the apiserver's advertise address must match it.
The API VIP stays on `mgmt0` for kubectl. No in-cluster client uses it:
kubelet, scheduler, controller-manager and Cilium go through KubePrism
(`localhost:7445`), which balances over every address of every control-plane
member; pods use the ClusterIP.
Talos never updates an existing etcd member's peer URLs
(siderolabs/talos#12720). Changing `advertisedSubnets` means, per member:
stage the config, `talosctl etcd forfeit-leadership`, `talosctl etcd leave`,
reboot. `leave` removes only `/var/lib/etcd`; the node rejoins as a learner
and is promoted. EPHEMERAL does not need a wipe.
## Moving node IPs: the mixed state
While some nodes have their InternalIP on the new network and some on the old,
host→remote-pod traffic leaves with a node address that is not an InternalIP:
the kernel picks the source from the route to the peer's InternalIP. Cilium
classifies that as `world`, and every ingress policy that admits only
`host`/`remote-node` (rook-ceph, monitoring, …) drops it. Kernel Ceph clients
lose the OSDs on the other side; RBD mounts hang.
Bridge it for the duration with a temporary `CiliumClusterwideNetworkPolicy`
admitting the node subnets of both networks, both families, with
`enableDefaultDeny: false` so it restricts nothing else, and delete it once
every node has moved. Keep all OSD nodes schedulable while one is drained:
draining two takes Ceph below its data availability.
## Failure behaviour
If bird stops, table 47 empties, the rules find nothing and fall through to
main: pod traffic returns to the node networks, unseparated rather than
misrouted. The same holds during a cold rebuild until Gitea serves bird's
image. The default route lives in table 47, not in the Talos config, so a
default cannot outlive the specific routes and send pod traffic to the UDM.
## Sources
Cilium v1.20 `pkg/datapath/linux/node.go`; BIRD 2.17 user manual (kernel
protocol, `strict bind`, `direct`); Talos v1.13 `RoutingRuleConfig`,
`machine.nodeAnnotations`, configuration patching (lists are appended,
`$patch: delete`), etcd `LeaveCluster`; KubePrism docs;
siderolabs/talos#12720. PRs #831, #833, #835, #836, #837, #842, #852, #854,
#855, #857, #858, #918, #919, #922; closed unmerged: #830, #838, #839.
@@ -1,194 +0,0 @@
# Proposal — separate the four traffic classes onto their own VLANs
Status: draft. The question this started with is **answered** — from the Cilium
source and flag reference, not from a test — and the answer changes the shape
of the proposal rather than confirming it.
Supersedes the framing in issue #576, which described this as "give node
management its own NIC, and Ceph public its own VLAN". That was two of four
classes and one of them turned out already solved.
## The four classes
| class | what it is | where it goes today |
|---|---|---|
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
Three of four share one interface. That is not a design decision — until the
bare-metal conversion there was one NIC to work with.
Ceph client I/O living on the pod network is correct rather than a leftover:
that traffic *is* cluster-internal. Ceph is done.
## Principle
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
separate, changeable decision.**
Defining classes by VLAN means the separation survives recabling, port failures
and capacity changes, and it does not require one port per class. Defining them
by interface would freeze a capacity decision into the architecture.
## The finding that shapes everything else
The original open question was whether `nodePort.directRoutingDevice` moves the
next-hop of Cilium's direct routes, or only selects an interface. It is
answerable from the source, and the answer is: **only the interface.**
`installDirectRoute` takes the next-hop as a parameter:
```go
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
skipUnreachable bool) (routeSpec *netlink.Route, err error)
```
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
cluster there is no path by which Cilium's node IP differs from the kubelet's
`InternalIP`.
`--direct-routing-device` is documented as "Device name used to connect nodes in
direct routing mode" — a device, not an address.
**So cluster-internal traffic is welded to the node IP, and the node IP is the
kubelet InternalIP.** Two consequences:
- The class cannot be moved off whatever VLAN the node IP lives on.
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
from the node IP; native mode uses it as the direct-route next-hop. Native
routing buys nothing here, so the disruptive, non-rolling step this proposal
previously contained is simply gone.
## Consequence: draw the line the other way
If cluster-internal cannot leave the node IP, then separation has to come from
moving *management services* off it — and each one has to be checked
individually, because they do not share a mechanism.
| management service | can it leave the node IP? | lever |
|---|---|---|
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
| kubelet | **no** | it *is* the node IP, by definition |
So four VLANs are reachable, but the boundary does not fall where the class
names suggest: **kubelet stays with cluster-internal.** That is the honest
result, and it is worth stating plainly rather than filing kubelet under
management and hoping.
The remaining classes keep the levers established earlier:
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
For eBGP the session source is the next-hop, so pinning the session pins
where return traffic arrives. The session to the UDM has been running for
months.
- **cluster-external egress** — eBPF masquerading follows the output device, so
this is a default-route decision plus `enable-masquerade-to-route-source`
(today `false`).
- **Ceph replication** — Multus macvlan on VLAN 49, done.
## The general escape hatch
Ceph is separated because its OSDs got a second interface and stopped using the
pod network for replication. That is the *only* mechanism that fully detaches a
workload from the node IP, and it generalises: anything that must not ride the
node IP gets its own `NetworkAttachmentDefinition`.
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
change. Worth it for Ceph replication. Not worth it for east-west traffic in
general.
## Recommended mapping
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
and Talos API. Cleanest match to the class names, but it changes every node's
identity, which is the one address the whole cluster is administered through.
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
Identical end state, and no node ever changes its InternalIP.
Under B:
| VLAN | carries |
|---|---|
| 49 | Ceph replication |
| 40 (relabelled *cluster*) | pod east-west, kubelet |
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
| new *external* | LoadBalancer ingress, egress default route |
The property the user asked for — management carries only management traffic —
holds under B. The label moves; the property is what matters.
## Migration shape
Every step is rolling and reversible. The routing-mode change is gone.
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
Still unused; this only proves the L2 works.
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
waiting for a healthy quorum between each.
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
`k8s.renner.uno` follows.
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
Reversible by reverting the override; failure is visible immediately in
`cilium-dbg shell -- bgp/peers`.
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
Step 3 is the one that deserves care — etcd is the only step where a mistake
costs quorum rather than connectivity.
## What this does not buy
Management currently shares a 10 Gb link and is not contended. Nothing observed
today is slow because of this. The case for the split is that traffic classes
with different failure modes and security postures should not be
indistinguishable — not that something is broken.
## Sources and measurements
Source, Cilium v1.20.1 (the running version):
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
a next-hop that is not directly reachable.
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
Kubernetes Node.
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
to connect nodes in direct routing mode".
Talos:
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
defaults to it. kube-apiserver and Talos API listen addresses are not
covered there.
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
`10.2.40.60` with `link: mgmt0`.
Measured on the cluster:
- `apid` answered on a node address that is not the InternalIP, so the Talos
API is not bound to one address.
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
InternalIP had changed since the last session, which is why nothing here
names an address as fixed.
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
unset and no direct-routing device configured.
Not verified: whether the kube-apiserver's `--advertise-address` follows the
node IP independently of the VIP. It does not affect the recommendation, since
clients reach the API through the VIP either way.
+315
View File
@@ -0,0 +1,315 @@
# Wiki: local web interface and agent API, as a modular .NET solution
## Goal
A web interface for the Notes wiki that runs entirely in the cluster, and an
HTTP API through which agents ask the wiki typed questions and deposit facts.
Built as one C# solution with a MudBlazor front end, and built so that every
external dependency is replaceable: where the notes live, which model embeds
them, what judges a typed question, what runs an agent, how the vector index
is stored. The first adapters are what the homelab uses today. The point of
the design is that the next one is a project and a config line, not a
rewrite.
## Where we are
- `wiki-index` (Python, ns `ai`) syncs `wiki/` from `k.renner/notes` by blob
SHA, embeds through `wiki-embed` (bge-m3 on llama-server), answers
`POST /rank`. It keeps slug, title and vector, not the text. LAN-only behind
Traefik BasicAuth; the Gitea webhook hits the ClusterIP.
- `~/Notes/bin/wiki_rank.py` and `wiki_ask.py` rank through the index, build a
document context from a local vault checkout, send it with typed questions
to TypeSafe, gate on `covered`. The wiki-synth agent composes answers from
those judgements.
- New knowledge enters as a file in `raw/` (facts with provenance, never
hypotheses), distilled nightly in CI.
- The house C# project, shelly-manager, sets the conventions: net10.0, an
`.slnx` with `src/` and `test/`, xUnit, MudBlazor 9, Blazor Web App with
interactive server rendering, minimal APIs, an Authentik proxy-header
authentication handler for humans and an API-key handler for machines, a
multi-stage Dockerfile, `dotnet test` on every push and a kaniko release
pipeline to the Gitea registry. Human-facing apps sit behind an Authentik
outpost (`<app>-gate`) with a CNP that admits only that outpost.
## Architecture: ports in the core, adapters around it
```
┌──────────────────────────────────────────┐
│ Wiki.Host (ASP.NET Core + Blazor/Mud) │ composition root,
│ /api/v1, /rank, /webhook, UI pages │ config, auth
└───────────────┬──────────────────────────┘
│ uses
┌───────────────▼──────────────────────────┐
│ Wiki.Application (use cases) │ Sync, Rank, Ask,
│ │ DepositFact, Queue-
└───────────────┬──────────────────────────┘ Question, Evaluate
│ depends only on
┌───────────────▼──────────────────────────┐
│ Wiki.Core (domain + ports) │ Note, RawSource,
│ no package references │ Report, LinkGraph,
└──────────────────────────────────────────┘ the interfaces
implemented by ▲ ▲ ▲ ▲ ▲
Adapters.Gitea Adapters.LlamaCpp Adapters.TypeSafe Adapters.FileIndex Adapters.Anthropic
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
```
Rules that keep it modular, enforced by project references and a test:
- `Wiki.Core` references nothing. It holds the domain types and the port
interfaces. Adapters reference Core only; they never reference each other
or the host. `Wiki.Host` is the only project that knows which adapters
exist.
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
runs against every adapter of that port. A new adapter is done when the
suite is green. That is what makes "swap the storage" a safe operation.
- Adapters are selected by configuration and registered by name (keyed
services), so several can be active at once. The UI's lab page and the
evaluator use that to compare, the API uses the one marked active.
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
Kubernetes.
### Ports
| port | what it abstracts | first adapter | examples of the next ones |
|---|---|---|---|
| `INoteStore` | read notes, raw sources and reports at a head; create a new document (a fact becomes a raw note in the application layer, the store only moves text) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
Two ports deserve a sentence of scope each. The judge returns judgements
with probabilities, never prose, and the UI never presents its output as an
answer, only as ranked, quoted evidence. The answer model turns that
evidence into words and is allowed nothing else: it sees the selected
paragraphs and the judgements, not the whole context, and its output is
shown next to the evidence it was given. Both are single model calls inside
the request. The service runs no agent: no tool loop, no vault edits. The
wiki-synth agent in a Claude session remains the path for artifacts and for
multi-question synthesis, and it can call `/questions` itself.
### Solution layout
```
Wiki.slnx
src/
Wiki.Core/ domain types, ports, frontmatter and link parsing
Wiki.Application/ use cases; depends on Core only
Wiki.Adapters.Gitea/ INoteStore (read at a head, create)
Wiki.Adapters.LlamaCpp/ IEmbeddingModel (OpenAI-compatible embeddings)
Wiki.Adapters.TypeSafe/ IJudge (System One client)
Wiki.Adapters.FileIndex/ IVectorIndex (memory + snapshot per model)
Wiki.Adapters.Anthropic/ IAnswerModel (Messages API, streaming)
Wiki.Host/ ASP.NET Core: API, webhook, MudBlazor UI, auth,
composition root, appsettings
test/
Wiki.Core.Tests/ parsing, link graph, chunker
Wiki.Contracts/ one suite per port, parameterised over adapters
Wiki.Application.Tests/ use cases with fake ports
Wiki.Host.Tests/ API surface, auth handlers
```
Own repository `k.renner/wiki`, same shape as shelly-manager: `Dockerfile`,
`.woodpecker/test.yml` on pull requests, `.woodpecker/release.yml` building
the image with kaniko into the Gitea registry on push to `main`, tagged
`build.<unix-ts>` plus the commit SHA so Flux image automation can follow
it. The homelab repo holds only the manifests. `claude-bot` needs write
access to the new repository.
Two things the shelly-manager history settles in advance: the TypeSafe
client is written from the documented field contract (endpoint, bearer key,
`jev-latest`, Noul/Choice/Score, 255-option cap, 32k-token state budget),
there is no prior C# client to copy; and the UI needs no login page, because
identity comes from the outpost's headers, which avoids the Blazor-circuit
sign-in constraint shelly-manager ran into.
### Configuration
Adapters are declared in `appsettings` and selected by name; secrets come
through environment variables, filled by ExternalSecret, never from the file.
```json
"Wiki": {
"NoteStore": { "Active": "gitea", "gitea": { "Repository": "k.renner/notes", "Branch": "main" } },
"Embeddings": {
"Active": "bge-m3",
"Models": {
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc:8080", "Model": "bge-m3" },
"e5-large": { "Adapter": "Ollama", "Url": "http://ollama.ai.svc:11434", "Model": "multilingual-e5-large" }
}
},
"Judges": {
"Active": "typesafe",
"Models": {
"typesafe": { "Adapter": "TypeSafe", "Url": "https://api.typesafe.ai/v1/systemone", "Model": "jev-latest" },
"jev-local": { "Adapter": "TypeSafe", "Url": "http://jev.ai.svc:8080/v1/systemone", "Model": "jev" } // illustration: same adapter, other endpoint
}
},
"Index": { "Active": "file", "file": { "Path": "/data/index" } },
"AnswerModels": {
"Active": "claude-sonnet",
"Models": {
"claude-sonnet": { "Adapter": "Anthropic", "Model": "claude-sonnet-5" },
"qwen-local": { "Adapter": "OpenAI", "Url": "http://llama-server.ai.svc:8080/v1", "Model": "qwen3.6-35b-a3b" }
}
}
}
```
Every embedding model gets its own index namespace. Switching the active
model is a config change after the new index has been built in the
background; until then both exist and can be compared. Nothing is
re-embedded in place.
## The web interface (MudBlazor)
Blazor Web App, interactive server rendering, hosted in `Wiki.Host`. Static
assets vendored into the image, no CDN. Pages:
1. **Search**: a query box, results ranked by the active index with score and
tags; for a phrased question, optionally the paragraph-level hits from the
judge with their probabilities, shown as quoted evidence.
2. **Note**: frontmatter, rendered body with `[[wikilinks]]` resolved,
backlinks, sources linking to their raw file.
3. **Browse**: by tag, by `updated`, recently changed, orphans.
4. **Answers**: a question box. The answer streams in within seconds: rank,
judge, compose, all in the request. Below it the evidence: the paragraphs
the judge selected with their notes and probabilities, and the `covered`
gate; a low gate is shown as "the wiki does not cover this" instead of an
answer. A save button writes the answer to `output/` with the wiki-synth
frontmatter (`question`, `sources`, `confidence`), so it joins the
labelled set. Underneath, the saved answers, newest first, including the
ones the wiki-synth agent wrote from a session.
5. **Maintenance**: the raw queue with ages, the last consolidation and audit
reports, index and sync status.
6. **Lab**: pick any registered embedding model and judge, run the same query
through them side by side, and run the evaluator on the labelled set to
get recall@k per configuration. This is the page for "play around with
different models".
7. **Settings**: read-only view of the active adapters and their health.
No editing of notes in the browser. The vault stays a git repository edited
by the consolidate agent and by hand.
## API v1
Reads are authenticated by the hostname's gate (humans) or API key
(machines); writes always need an API key so a fact carries its author.
| method and path | purpose |
|---|---|
| `GET /api/v1/status` | index commit and age, counts, active adapters |
| `GET /api/v1/notes?q=&tag=&limit=` | list; `q` ranks through the active index |
| `GET /api/v1/notes/{slug}` | frontmatter, body, links, backlinks, sources |
| `GET /api/v1/tags` | tags with counts |
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
| `GET /api/v1/raw` | the distill queue with ages |
| `GET /api/v1/reports` | `output/` documents by type, newest first |
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
| `POST /webhook` | Gitea push, HMAC as today |
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
`fact`, or with content under a minimum length. The raw rule is enforced by
shape: there is no field for a hypothesis. The file gets the vault's
provenance frontmatter with `captured_by` set from the API key's identity.
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
thin clients and agents no longer need a vault checkout. Its result is
judgements and quotations, never prose.
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
rank the question, build the context, run the default typed questions
(answered, where, covered), hand the selected paragraphs and judgements to
the answer model, stream the prose back. Below the `covered` threshold it
returns the gap instead of an answer. Time is the sum of the parts, about
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
call over a few thousand tokens, a few cents on Sonnet, against a quarter
dollar for a full agent run.
## Auth and network
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
outpost.
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
key stays where it is. CI reaches the ClusterIP.
- Secrets (Gitea token, webhook secret, API keys, TypeSafe key, Anthropic
key) reach the
pod as environment variables from Kubernetes Secrets filled by
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
no OpenBao access. The Python service fetched its secrets from OpenBao
directly through its own Kubernetes-auth role; that role, its policy and
the `ai` → `openbao` CNP entry are retired with it.
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
`api.anthropic.com:443`. Every
further adapter that talks to a new endpoint gets its own CNP line, which
is the one place adding an adapter touches the cluster.
## Health endpoints
Three endpoints with three different meanings, wired to the .NET health
checks with tags so that the probes and the humans see the right thing:
| endpoint | meaning | probe |
|---|---|---|
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
Adapter checks (Gitea reachable, embeddings endpoint answering, judge
endpoint answering, answer model answering) are reported in `/status` and
on the Settings page and are never part of `/ready`: a judge outage must not
take the reader down. Their failures show up as red entries, not as
restarts.
## Phases
One PR each, verified before the next. The Python `wiki-index` keeps running
until phase 3 proves parity.
0. **Skeleton.** Repository, solution, `Wiki.Core` with the ports and domain
types, `Wiki.Contracts` with the suites, `Wiki.Host` serving `/health`,
`/ready` and `/api/v1/status`. CI: test and release pipelines. Verify:
image in the registry, pipeline green, the Deployment's three probes
wired and the pod Ready.
1. **Parity with the Python service.** Gitea, LlamaCpp and FileIndex
adapters, the sync use case, `/rank` and `/webhook`. Verify: the 40-query
recall eval gives the same numbers as the Python service on the same
commit; `wiki_rank.py --probe-index` against the new service passes.
2. **Read API and UI.** Notes, tags, raw, reports; Search, Note, Browse,
Maintenance pages; `wiki-gate`. Verify: a note renders with resolved
links; an unauthenticated request is redirected to Authentik.
3. **Ask.** Chunker, TypeSafe adapter, `/api/v1/ask`, scripts as clients.
Verify: `test_wiki_ask.py` passes against the API; a wiki-synth run with
the definition unchanged. Then the Python service is retired and the
`wiki-index` hostname points at the new one.
4. **Facts.** API keys as an ExternalSecret, the deposit-fact use case, `/api/v1/facts`. Verify:
a fact posted from the workstation shows as a `claude-bot` commit and in
the next distill.
5. **Questions.** Anthropic adapter, `/api/v1/questions` with streaming,
the Answers page with save. Verify: a question answered in under ten
seconds with citations that resolve to the shown evidence; a question the
wiki does not cover returns the gap; a saved answer appears in `output/`
with the wiki-synth frontmatter and is picked up by the evaluator.
6. **Lab.** Evaluator, a second embedding model and a second answer model
registered, the Lab page. Verify: two models compared on the labelled set
from the UI.
## Not in scope
- Editing notes in the browser.
- Replacing Obsidian; the vault stays a git repository and `notes-push.path`
keeps pushing.
- Public exposure; LAN and VPN only.
- Running or triggering agents. The service makes single model calls inside
a request; it never runs a tool loop or edits the vault. Distill,
consolidate and multi-question synthesis stay Claude processes in CI or in
a session; the service shows their inputs and outputs and serves their API
calls.
+2 -2
View File
@@ -432,8 +432,8 @@ is why it belongs here.
among its devices but not `ens19`, because `ens19` is deliberately
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
VLAN carrying pod traffic will need the same treatment.
on the shared parent `uplink`, still tagged. Any future address-less VLAN
carrying pod traffic will need the same treatment.
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
+61 -35
View File
@@ -67,10 +67,12 @@ These must exist outside the cluster or the rebuild cannot complete:
`flux` phase creates the `sops-age` Secret, and any not-yet-migrated sops
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
`MIGRATION-sops-openbao.md`.)
- [ ] **OpenBao unseal key + root token** — in the GPG `pass` store
(`homelab/openbao/unseal-key`, `homelab/openbao/root-token`), and the GPG
private key backed up off-machine. Without the unseal key, no OpenBao
snapshot can be restored and no secret-consuming app can start.
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
and the GPG private key backed up off-machine. The seal key is the one
the snapshots are encrypted under: without it, no OpenBao snapshot can be
restored and no secret-consuming app can start.
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
@@ -105,7 +107,7 @@ config, and the nodes are addressed by name.
## Step 1b — boot all three machines from install media
```
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.10/metal-amd64.iso
```
Write it to a USB stick and boot each MS-01 from it. Each comes up in
@@ -294,42 +296,65 @@ output** — a green step over a still-forming Ceph is a known trap.
> Steady-state (non-rebuild) bootstrap is just `kubernetes/bootstrap.sh flux`
> with `GITEA_TOKEN` set; `FLUX_SOURCE` defaults to `gitea`.
## Step 4 — openbao: restore the snapshot + unseal
## Step 4 — openbao: restore the snapshot
After Step 3, OpenBao comes up **sealed and empty**. Until it is restored and
unsealed, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
Secrets, and every secret-consuming app stays Pending. This step is **manual**
(the unseal key lives in `pass`, off-cluster by design).
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
the empty server, restores the newest raft snapshot straight out of the Velero
kopia repository, and waits until every replica has unsealed itself from the
static seal. What follows is the same procedure by hand, for when the script
is not an option.
OpenBao unseals itself from the static seal (`openbao-seal-key` Secret, key
`key`, the 32-byte base64 key from `pass show homelab/openbao/seal-key`). The
snapshots are encrypted under that key, so it must be the same key the backed
up cluster used — a different one restores nothing. Until the snapshot is
restored, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
Secrets, and every secret-consuming app stays Pending.
```sh
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
kubectl -n openbao get pods
# 1. The seal key, before the pods start (a pod without it never starts).
kubectl create namespace openbao --dry-run=client -o yaml | kubectl apply -f -
kubectl -n openbao create secret generic openbao-seal-key \
--from-literal=key="$(pass show homelab/openbao/seal-key)"
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
# 2. Wait for openbao-0 (uninitialised: 0/1 Ready is expected), then
# initialise it. With an auto-seal, init hands out recovery keys and the
# server unseals itself. These keys are throwaway: the restore replaces them.
kubectl -n openbao exec -it openbao-0 -- bao operator init \
-recovery-shares=1 -recovery-threshold=1
# 3. Restore the most recent raft snapshot. Source it from the snapshot PVC
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
# from the Velero `openbao-daily` offsite copy. Copy it into the pod, then:
# from the Velero `openbao-daily` offsite copy. Root token from the init
# output. No -force: OpenBao checks the snapshot was written under this
# server's seal key and refuses otherwise ("different autoseal key") — that
# refusal is the signal the Secret is wrong. -force would skip the check and
# leave a server that can never unseal.
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
kubectl -n openbao exec -it openbao-0 -- \
env BAO_TOKEN=<init root token> bao operator raft snapshot restore /tmp/restore.snap
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
# A sealed server is neither a raft voter nor a standby, so unsealing only
# openbao-0 leaves the three-peer cluster one node short of quorum.
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
# 4. The server seals against the snapshot's keyring and unseals itself from
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
# to type. Afterwards the tokens and recovery key from pass apply again.
kubectl -n openbao get pods -w
```
Verify the store recovers before moving on:
```sh
kubectl get clustersecretstore openbao # want: Valid / Ready
kubectl -n <ns> get externalsecret # want: SecretSynced
kubectl -n openbao exec openbao-0 -- bao status # want: Seal Type static, Sealed false
kubectl get clustersecretstore openbao # want: Valid / Ready
kubectl -n <ns> get externalsecret # want: SecretSynced
```
> **Manual unseal is required after every OpenBao pod restart**, not just
> during rebuilds, and for *each* replica that restarts — a rolling node
> drain seals all three in turn. Details + rationale:
> `MIGRATION-sops-openbao.md`.
> A replica that stays **sealed** after a restart could not use the static
> seal — the Secret is missing or does not match the key the data was
> written under. Fix the Secret and restart the pod; `bao operator unseal`
> with the recovery key (`pass show homelab/openbao/unseal-key`) does not
> unseal an auto-sealed server and is only for recovery-mode operations.
> Details + rationale: `MIGRATION-sops-openbao.md`.
## Step 5 — apps: let the rest reconcile
@@ -393,14 +418,15 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
It has also never had an `openbao` phase — Step 4 is manual because the
unseal key must not live in CI; see issue #187 on whether to wire a
semi-automated OpenBao restore.
- **OpenBao seals on every restart and each replica must be unsealed
individually.** Three raft replicas mean the service survives losing one, but
a sealed server is neither a voter nor a standby — unsealing only one leaves
the cluster short of quorum. Step 4 covers this; it is repeated here because
it is the step most likely to be half-done under pressure.
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
other secrets are read from `pass` or streamed in at run time and never live
in CI.
- **OpenBao unseals itself, but only under the seal key the snapshot was
written with.** Three raft replicas mean the service survives losing one; a
sealed server is neither a voter nor a standby. A pod that stays sealed after
a restart has the wrong `openbao-seal-key` Secret — Step 4 covers this; it is
repeated here because it is the step most likely to be half-done under
pressure.
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
IPAM, not host networking. Nothing in the rebuild needs special handling for
it — the nodes hold no address on that segment — but a rebuilt cluster will
@@ -21,6 +21,9 @@ metadata:
namespace: audiobookshelf
labels:
app.kubernetes.io/name: audiobookshelf
annotations:
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
configmap.reloader.stakater.com/reload: audiobookshelf-config
spec:
replicas: 1
strategy:
@@ -67,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.36.0 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.36.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -119,7 +122,7 @@ spec:
claimName: audiobookshelf-metadata
- name: books
persistentVolumeClaim:
claimName: audiobookshelf-books
claimName: audiobookshelf-books-bulk
- name: le-ca
emptyDir:
medium: Memory
+2 -2
View File
@@ -35,14 +35,14 @@ spec:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: audiobookshelf-books
name: audiobookshelf-books-bulk
namespace: audiobookshelf
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
storageClassName: ceph-filesystem-bulk
resources:
requests:
storage: 300Gi
@@ -976,6 +976,201 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# wiki-gate: the wiki service (ns ai) behind the same passkey flows as
# llama-gate. The service trusts X-Authentik-Username only from pod
# addresses, and its CNP admits only this outpost, so the gate is the
# whole access control for wiki.renner.uno.
- id: wiki-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: wiki-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://wiki.ai.svc.cluster.local:8080
internal_host_ssl_validation: false
external_host: https://wiki.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: wiki-gate
state: present
attrs:
name: Wiki
slug: wiki-gate
provider: !KeyOf wiki-gate-provider
meta_launch_url: https://wiki.renner.uno
meta_description: "Notes wiki: search, notes, answers, maintenance"
- model: authentik_outposts.outpost
identifiers:
name: wiki-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf wiki-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it.
- id: paperclip-users-group
model: authentik_core.group
identifiers:
name: Paperclip Users
state: present
attrs:
is_superuser: false
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: paperclip-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: paperclip-gate
state: present
attrs:
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://paperclip.paperclip.svc.cluster.local:3100
internal_host_ssl_validation: false
external_host: https://paperclip.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: paperclip-gate
state: present
attrs:
name: Paperclip
slug: paperclip-gate
provider: !KeyOf paperclip-gate-provider
meta_launch_url: https://paperclip.renner.uno
meta_description: "Paperclip agent orchestration"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, paperclip-gate]]
group: !KeyOf paperclip-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
- model: authentik_outposts.outpost
identifiers:
name: paperclip-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf paperclip-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# navidrome-gate: the Navidrome web UI. Navidrome auto-creates the
# X-Authentik-Username user on first login; the first user is admin.
# /rest and /share bypass the gate at the Traefik router.
- id: navidrome-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: navidrome-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://navidrome.navidrome.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://navidrome.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: navidrome-gate
state: present
attrs:
name: Navidrome
slug: navidrome-gate
provider: !KeyOf navidrome-gate-provider
meta_launch_url: https://navidrome.renner.uno
meta_description: "Music streaming (Subsonic)"
- model: authentik_outposts.outpost
identifiers:
name: navidrome-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf navidrome-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
@@ -7,6 +7,7 @@ resources:
- helmrelease.yaml
- paperless-gate-ingress.yaml
- paperless-gate-egress.yaml
- paperclip-gate-egress.yaml
- vscode-gate-egress.yaml
- shelly-manager-gate-egress.yaml
- esphome-gate-egress.yaml
@@ -16,6 +17,8 @@ resources:
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- llama-gate-egress.yaml
- wiki-gate-egress.yaml
- navidrome-gate-egress.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
@@ -40,7 +40,7 @@ metadata:
# so this Service produces no public record — LDAP stays
# LAN-internal, which matches the threat model of the cleartext
# bind on 389.
external-dns.alpha.kubernetes.io/hostname: ldap.renner.uno
external-dns.kubernetes.io/hostname: ldap.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
@@ -0,0 +1,20 @@
# Lets the navidrome-gate outpost reach Navidrome; the matching ingress rule
# is in network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: navidrome-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: navidrome-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: navidrome
toPorts:
- ports:
- port: "4533"
protocol: TCP
@@ -0,0 +1,19 @@
# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paperclip-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: paperclip-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperclip
app.kubernetes.io/name: paperclip
toPorts:
- ports:
- port: "3100"
protocol: TCP
@@ -0,0 +1,21 @@
# Lets the wiki-gate outpost reach the wiki service. The authentik namespace
# baseline CNP otherwise stops any authentik pod from entering ai. Scoped to
# this outpost's pod label; the matching ingress rule is in ai-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wiki-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: wiki-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: wiki
toPorts:
- ports:
- port: "8080"
protocol: TCP
@@ -3,3 +3,4 @@ kind: Kustomization
resources:
- externalsecret.yaml
- helmrelease.yaml
- networkpolicy.yaml
@@ -0,0 +1,22 @@
# The apps namespace is default-deny-ingress, so the ServiceMonitor added in
# monitoring-rules/external-dns-servicemonitor.yaml scraped into a timeout.
# The selector matches both releases — the Cloudflare one deployed here and
# external-dns-unifi from infrastructure/ — since they share the chart's
# app.kubernetes.io/name and land in the same namespace.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: external-dns-allow-metrics-scrape
namespace: apps
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: external-dns
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: "7979"
protocol: TCP
+1 -1
View File
@@ -46,7 +46,7 @@ spec:
type: RuntimeDefault
containers:
- name: gatus
image: twinproduction/gatus:v5.36.0 # {"$imagepolicy": "flux-system:gatus"}
image: twinproduction/gatus:v5.37.0 # {"$imagepolicy": "flux-system:gatus"}
securityContext:
allowPrivilegeEscalation: false
capabilities:
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
annotations:
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: gitea-ssh
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
-1
View File
@@ -6,7 +6,6 @@ kind: Kustomization
# and verified. See MIGRATION-gitea-woodpecker.md.
resources:
- statefulset.yaml
- pdb.yaml
- service.yaml
- ingressroute-ssh.yaml
- ingress.yaml
+6 -26
View File
@@ -1,28 +1,8 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-metrics-scrape
namespace: apps
spec:
# Prometheus (monitoring ns) scrapes gitea's /metrics on :3000. The apps
# default-deny-ingress only allows same-ns / home-assistant / host, so the
# scrape is dropped (context deadline exceeded). This became necessary only
# after the LXC->in-cluster cutover — the old static target was external, so
# no in-cluster policy applied. Scoped to the gitea pod + :3000.
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
toPorts:
- ports:
- port: "3000"
protocol: TCP
---
# Prometheus scrape and Traefik are admitted by the namespace baseline in
# infrastructure/network-policies/gitea-ns.yaml.
#
# Secret rotation: the flux-git rotator (openbao ns) reaches gitea to mint a new
# flux PAT. It uses the in-cluster Service (gitea.apps.svc:3000) — the public
# flux PAT. It uses the in-cluster Service (gitea.gitea.svc:3000) — the public
# gitea.renner.uno name hairpins off the ingress LB from a pod (same failure as
# pve.renner.uno). Scoped to the gitea pod :3000 and the rotator label; the
# egress half is rotator-gitea-flux-egress in network-policies/openbao-ns.yaml.
@@ -30,7 +10,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-flux-rotator
namespace: apps
namespace: gitea
spec:
endpointSelector:
matchLabels:
@@ -54,7 +34,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-review-bot-rotator
namespace: apps
namespace: gitea
spec:
endpointSelector:
matchLabels:
-23
View File
@@ -1,23 +0,0 @@
---
# Eviction protection for the single-replica Gitea StatefulSet.
#
# Gitea runs on SQLite (single writer) and its repos + DB live on a single
# ReadWriteOnce PVC, so it is deliberately replicas:1 and cannot fail over.
# With no PDB, a node drain / maintenance evicts the pod abruptly, mid-write,
# with zero coordination. minAvailable:1 on a 1-replica workload makes the
# eviction API refuse *voluntary* evictions: `kubectl drain` blocks and
# surfaces this pod, forcing a conscious decision (drain with
# --disable-eviction, or scale down first) instead of yanking the live DB.
# It does NOT block involuntary disruptions (node crash, OOM).
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: gitea
namespace: apps
labels:
app.kubernetes.io/name: gitea
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: gitea
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
+2 -2
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: StatefulSet
metadata:
name: gitea
namespace: apps
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
@@ -152,7 +152,7 @@ spec:
# 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 apps --cascade=orphan`
# 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.
+3 -1
View File
@@ -4,7 +4,9 @@ metadata:
name: gf-pg
namespace: monitoring
spec:
instances: 1
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
storageClass: ceph-block
@@ -18,7 +18,7 @@ metadata:
lb-pool: bgp
annotations:
lbipam.cilium.io/ips: "10.2.99.87,fd99::87"
external-dns.alpha.kubernetes.io/hostname: ha.renner.uno
external-dns.kubernetes.io/hostname: ha.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
@@ -95,7 +95,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.9.1 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.9.3 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -30,7 +30,9 @@ metadata:
name: ha-pg
namespace: home-assistant
spec:
instances: 1
instances: 2
smartShutdownTimeout: 10
stopDelay: 60
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
storageClass: ceph-block
@@ -124,8 +126,7 @@ spec:
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
# its volumes are lost outright.
archive_timeout: "30min"
# Fewer checkpoints => fewer full-page writes, since a page is only
# fully-logged on its first modification after a checkpoint.
@@ -16,6 +16,9 @@ resources:
# health broker credential: OpenBao-backed + Class-A rotated (first of the
# eight users to migrate off mosquitto-bootstrap.yaml).
- mosquitto-health-credentials-externalsecret.yaml
# open3e-int broker credential: open3e-esp32 gateway on the Vitocal internal
# CAN; OpenBao-backed + Class-A rotated.
- mosquitto-open3e-int-credentials-externalsecret.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
@@ -12,7 +12,7 @@ metadata:
#
# Listed per-Secret rather than with the blanket auto annotation, so that
# enrolling another broker user is a visible edit to this line.
secret.reloader.stakater.com/reload: mosquitto-health-credentials
secret.reloader.stakater.com/reload: mosquitto-health-credentials,mosquitto-open3e-int-credentials
spec:
replicas: 1
strategy:
@@ -78,6 +78,7 @@ spec:
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd open3e "$OPEN3E_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd open3e-int "$OPEN3E_INT_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd evcc "$EVCC_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd health "$HEALTH_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
@@ -124,6 +125,12 @@ spec:
user open3e
topic readwrite open3e/#
# open3e-esp32 gateway on the Vitocal internal CAN: own subtree
# under open3e/ so edge-1's listener on open3e/cmnd never sees
# its commands.
user open3e-int
topic readwrite open3e/int/#
user evcc
topic read open3e/#
topic readwrite evcc/#
@@ -187,6 +194,13 @@ spec:
secretKeyRef:
name: mosquitto-open3e-credentials
key: password
# open3e-int: OpenBao-backed + Class-A rotated (see
# mosquitto-open3e-int-credentials-externalsecret.yaml).
- name: OPEN3E_INT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-open3e-int-credentials
key: password
- name: EVCC_PASSWORD
valueFrom:
secretKeyRef:
@@ -19,7 +19,7 @@ metadata:
# LAN can connect with `mqtt.renner.uno` (matches the broker cert
# SAN) instead of the bare IP. No Cloudflare label — kept off the
# public DNS; UniFi serves a split-DNS answer for LAN clients.
external-dns.alpha.kubernetes.io/hostname: mqtt.renner.uno
external-dns.kubernetes.io/hostname: mqtt.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
@@ -0,0 +1,21 @@
# Projects the mosquitto `open3e-int` broker credential (open3e-esp32 gateway
# on the Vitocal internal CAN) from OpenBao into the Secret the broker
# consumes. Rotator: kubernetes/infrastructure/openbao/rotation/mosquitto-open3e-int.yaml.
#
# creationPolicy is implicitly Owner: deleting this ExternalSecret deletes the
# live Secret. Set creationPolicy: Orphan first to detach without destroying.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-open3e-int-credentials
namespace: home-assistant
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-open3e-int-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-open3e-int-credentials
+1 -2
View File
@@ -59,8 +59,7 @@ spec:
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
# its volumes are lost outright.
archive_timeout: "30min"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
+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.1.0 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.2.2 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+32
View File
@@ -28,6 +28,38 @@ spec:
requests:
storage: 300Gi
---
# Media root on the EC 2+1 bulk pool; thumbs mount separately below.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-library-bulk
namespace: immich
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem-bulk
resources:
requests:
storage: 300Gi
---
# Thumbnails stay replicated: read affinity serves them from the local OSD.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-thumbs
namespace: immich
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 30Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+10 -2
View File
@@ -35,6 +35,9 @@ metadata:
namespace: immich
labels:
app.kubernetes.io/name: immich-server
annotations:
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
configmap.reloader.stakater.com/reload: immich-config
spec:
# Scaled to 0 alongside immich-machine-learning during single-node
# maintenance; see the note there. The CNPG Postgres cluster stays running,
@@ -84,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.1.0 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.2.2 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
@@ -107,6 +110,8 @@ spec:
volumeMounts:
- name: library
mountPath: /data
- name: thumbs
mountPath: /data/thumbs
- name: immich-config-file
mountPath: /config
readOnly: true
@@ -142,7 +147,10 @@ spec:
volumes:
- name: library
persistentVolumeClaim:
claimName: immich-library
claimName: immich-library-bulk
- name: thumbs
persistentVolumeClaim:
claimName: immich-thumbs
- name: immich-config-file
secret:
secretName: immich-secrets
@@ -61,6 +61,9 @@ spec:
enabled: true
storageClass: ceph-block
size: 50Gi
# A minAvailable:1 PDB on a single replica blocks every node drain.
pdb:
create: false
# Bootstrap admin/org/bucket on the fresh PVC. Admin password + token come
# from the influxdb-bootstrap Secret (OpenBao via ESO), deterministic across
# rebuilds so the bootstrap token is the live admin token. `influx setup`
+1 -1
View File
@@ -3,8 +3,8 @@ kind: Kustomization
resources:
- gatus/
- external-dns/
- gitea/
- ical-proxy/
# gitea/ has its own `gitea` Kustomization (clusters/homelab/gitea.yaml, #878).
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
# (clusters/homelab/woodpecker.yaml) so its wp-pg CNPG cluster's barman
# dependency + Cluster health-gating don't contaminate this shared apps KS.
+32 -6
View File
@@ -4,7 +4,7 @@
# the entrypoint exit after one pass instead of looping internally —
# the CronJob handles scheduling.
#
# Books land in /audiobooks (the audiobookshelf-books PVC, shared
# Books land in /audiobooks (the audiobookshelf-books-bulk PVC, shared
# with the audiobookshelf Deployment). Config + DB live on
# libation-config (RWO ceph-block, seeded once from
# libation-bootstrap secret).
@@ -42,9 +42,11 @@ spec:
type: RuntimeDefault
initContainers:
# Seed the libation-config PVC from the bootstrap secret on
# first run only — sentinel file gates re-runs so token
# refreshes in AccountsSettings.json and DB updates from
# subsequent liberate passes are preserved.
# first run only — sentinel file gates re-runs so DB updates
# from subsequent liberate passes are preserved. Refreshed
# Audible tokens are preserved by the container args, not by
# this sentinel: the entrypoint copies AccountsSettings.json
# into ephemeral /config-internal rather than symlinking it.
- name: seed-config
image: busybox:1.37.0
command: ["/bin/sh", "-eu", "-c"]
@@ -66,6 +68,11 @@ spec:
install -m 660 /bootstrap/AccountsSettings.json /libation-state/AccountsSettings.json
install -m 660 /bootstrap/Settings.json /libation-state/Settings.json
install -m 660 /bootstrap/LibationContext.db /libation-state/LibationContext.db
# Without this key the seeded tokens cannot be decrypted:
# TokenStorageMethod is Encrypted and the container has no
# OS secret store, so a missing key file makes the
# entrypoint mint a throwaway one on every run.
install -m 660 /bootstrap/libation-master.key /libation-state/libation-master.key
touch /libation-state/.bootstrapped
ls -la /libation-state
volumeMounts:
@@ -85,7 +92,26 @@ spec:
type: RuntimeDefault
containers:
- name: libation
image: docker.io/rmcrackan/libation:latest
# 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
# 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.
# Passing args makes the entrypoint set up the environment and
# then exec this instead of its own loop; the trap copies the
# refreshed file back to the PVC on every exit path.
args:
- /bin/sh
- -c
- |
trap 'cp /config-internal/AccountsSettings.json /libation-state/AccountsSettings.json' EXIT
set -e
# Strip the Audible intro/outro; re-liberated books replace their file instead of gaining a " (1)" copy.
jq '.StripAudibleBrandAudio = true | .OverwriteExisting = true' /config-internal/Settings.json > /config-internal/Settings.json.tmp
mv /config-internal/Settings.json.tmp /config-internal/Settings.json
/libation/LibationCli scan
/libation/LibationCli liberate
env:
- name: LIBATION_CONFIG_DIR
value: /libation-state
@@ -112,7 +138,7 @@ spec:
claimName: libation-config
- name: books
persistentVolumeClaim:
claimName: audiobookshelf-books
claimName: audiobookshelf-books-bulk
- name: bootstrap
secret:
secretName: libation-bootstrap
+1 -1
View File
@@ -5,7 +5,7 @@
# SOPS-encrypted Secret on first boot (sentinel file gate); after that
# Libation reads and writes it directly so token rotation persists.
#
# Books are written to the audiobookshelf-books PVC (already provisioned
# Books are written to the audiobookshelf-books-bulk PVC (already provisioned
# by the audiobookshelf seed Kustomization, RWX ceph-filesystem). We
# share the same namespace so the PVC reference is direct — k8s PVCs
# are namespace-scoped.
@@ -6,3 +6,5 @@ resources:
- embed.yaml
- gate-service.yaml
- ingress.yaml
- wiki-embed.yaml
- wiki-embed-ingress.yaml
@@ -0,0 +1,57 @@
---
# wiki-embed.renner.uno — LAN-only (no dns.renner.uno/public label), consumed by
# bin/wiki_rank.py on the workstation and the nightly wiki jobs. Machine client,
# so Traefik BasicAuth instead of the browser-only Authentik gate; same pattern
# as kubernetes/apps/monitoring/loki-ingress.yaml.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: wiki-embed-basic-auth
namespace: ai
spec:
basicAuth:
secret: wiki-embed-basic-auth
realm: wiki-embed
---
# OpenBao key apps/ai/wiki-embed-basic-auth, field `users` = htpasswd line.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wiki-embed-basic-auth
namespace: ai
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: wiki-embed-basic-auth
dataFrom:
- extract:
key: apps/ai/wiki-embed-basic-auth
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wiki-embed
namespace: ai
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: ai-wiki-embed-basic-auth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- wiki-embed.renner.uno
secretName: wiki-embed-tls
rules:
- host: wiki-embed.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wiki-embed
port:
number: 8080
@@ -0,0 +1,117 @@
---
# Embedding endpoint for the ~/Notes wiki ranker (bin/wiki_rank.py). Own
# Deployment so wiki traffic never queues behind Paperless on llama-embed.
# Same model and PVC as llama-embed, so no second download.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wiki-embed
namespace: ai
labels:
app.kubernetes.io/name: wiki-embed
spec:
replicas: 1
# Above the 600s startupProbe budget; see embed.yaml.
progressDeadlineSeconds: 900
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: wiki-embed
template:
metadata:
labels:
app.kubernetes.io/name: wiki-embed
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: wiki-embed
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args:
- --hf-repo
- gpustack/bge-m3-GGUF:Q8_0
- --embeddings
- --alias
- bge-m3
# bge-m3 is trained for CLS pooling; the GGUF default is not.
- --pooling
- cls
- --n-gpu-layers
- "99"
- --threads
- "2"
# wiki_rank.py caps each note at 1500 chars and sends 2 per request.
- --batch-size
- "1536"
- --ubatch-size
- "1536"
- --ctx-size
- "4096"
- --no-webui
env:
- name: LLAMA_CACHE
value: /models
ports:
- name: http
containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# Same sizing as llama-embed: 1.6 GiB measured peak, headroom for slots.
resources:
requests:
cpu: 250m
memory: 2Gi
gpu.intel.com/i915: "1"
limits:
cpu: "2"
memory: 8Gi
gpu.intel.com/i915: "1"
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: 10
failureThreshold: 6
volumeMounts:
- name: models
mountPath: /models
volumes:
- name: models
persistentVolumeClaim:
claimName: llama-server-models
---
apiVersion: v1
kind: Service
metadata:
name: wiki-embed
namespace: ai
labels:
app.kubernetes.io/name: wiki-embed
spec:
selector:
app.kubernetes.io/name: wiki-embed
ports:
- name: http
port: 8080
targetPort: http
@@ -0,0 +1,25 @@
# Both external-dns releases (Cloudflare and the UniFi webhook) carry
# app.kubernetes.io/name: external-dns and differ only by instance, so one
# monitor covers both. jobLabel keeps them apart as separate job values.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: external-dns
namespace: monitoring
labels:
app.kubernetes.io/name: external-dns
release: kube-prometheus-stack
spec:
jobLabel: app.kubernetes.io/instance
namespaceSelector:
matchNames:
- apps
selector:
matchLabels:
app.kubernetes.io/name: external-dns
endpoints:
# The controller's own metrics. Port 8080 on the unifi release is the
# webhook sidecar and exports no external_dns_* series.
- port: http
path: /metrics
interval: 60s
@@ -0,0 +1,20 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: intel-gpu-exporter
endpoints:
- port: http-metrics
path: /metrics
interval: 30s
relabelings:
# The series would otherwise carry only the exporter pod's IP.
- sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
@@ -21,6 +21,14 @@ resources:
# alerted if that stopped — HetznerBackupEndpointDown only probes the S3
# endpoint, which proves reachability, not that anything was written.
- prometheusrule-cnpg-backup.yaml
# DNS publication. Nothing scraped external-dns, so it deleted six LAN
# records on 2026-09-12 and stayed silent for five days.
- prometheusrule-external-dns.yaml
- external-dns-servicemonitor.yaml
# Outcome check for the same failure: the names themselves, asked of the
# UDM. Catches a cause external-dns cannot see, e.g. records removed there.
- prometheusrule-dns-resolution.yaml
- probes-dns.yaml
- alloy-servicemonitor.yaml
- cilium-servicemonitor.yaml
- cnpg-podmonitors.yaml
@@ -39,4 +47,5 @@ resources:
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
- intel-gpu-exporter-servicemonitor.yaml
- probes-blackbox.yaml
@@ -0,0 +1,143 @@
# DNS probes for the names external-dns publishes to the UDM from
# LoadBalancer Services. One Probe per name because the dns prober's
# query_name is module-scoped; the target is the resolver, queried on both
# families so a single-stack blip does not read as a missing record.
#
# The UDM is queried directly rather than through the cluster resolver:
# k8s-gateway caches these with `serve_stale 1h immediate`, which would keep
# a deleted record green for an hour.
#
# `release` is load-bearing — probeSelector matches on it.
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-ha
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_ha
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
# instance is the resolver and identical across these Probes, so the
# queried name has to come from a label.
labels:
dns_name: ha.renner.uno
---
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-mqtt
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_mqtt
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: mqtt.renner.uno
---
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-ldap
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_ldap
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: ldap.renner.uno
---
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-syslog
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_syslog
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: syslog.renner.uno
---
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-ftp-scanner
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_ftp_scanner
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: ftp.paperless.renner.uno
---
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: dns-lan-syncthing
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
jobName: blackbox-dns
prober:
url: blackbox-exporter-prometheus-blackbox-exporter.monitoring.svc.cluster.local:9115
module: dns_lan_syncthing
interval: 60s
scrapeTimeout: 10s
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
labels:
dns_name: syncthing.paperless.renner.uno
@@ -34,7 +34,9 @@ spec:
# query parameter, and an exact match would break silently if the
# instance label ever encodes it differently.
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
for: 5m
# Two consecutive failed probes: enough to skip a single lost probe
# through Traefik, not enough to sit on a sealed store.
for: 1m
labels:
severity: critical
annotations:
@@ -0,0 +1,49 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: dns-resolution
namespace: monitoring
labels:
# ruleSelector matches on this label; without it the rule is created but
# never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: dns-resolution.rules
rules:
# min by dns_name: both resolver families are probed, and the record
# is only gone when neither answers. A single-family failure is the
# UDM's dnsmasq refusing connections in bursts (k8s-gateway carries
# the same note), which is why the window is 30m rather than the 10m
# used for HTTP probes.
- alert: LanDnsRecordMissing
expr: min by (dns_name) (probe_success{job="blackbox-dns"}) == 0
for: 30m
labels:
severity: critical
annotations:
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
description: >-
The UDM stopped answering for a name external-dns publishes from
a LoadBalancer Service, or answered with the public
*.renner.uno wildcard instead of the record. Clients that use
the name rather than the address lose the service while
everything in the cluster stays healthy — nothing else in the
stack reports this. Check the external-dns alerts first, then
the record on the UDM.
# Dead-man's-switch, mirroring BlackboxProbesAbsent: without probe
# results the rule above cannot fire.
- alert: LanDnsProbesAbsent
expr: absent(probe_success{job="blackbox-dns"})
for: 15m
labels:
severity: critical
annotations:
summary: "No LAN DNS probe results are reaching Prometheus"
description: >-
The blackbox-dns Probes stopped being discovered or the exporter
rejects the modules. A 400 from /probe yields no probe_* series
at all, which looks the same as no Probe — check that every
dns_lan_* module the Probes reference exists in the
blackbox-exporter config.
@@ -0,0 +1,87 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: external-dns
namespace: monitoring
labels:
# ruleSelector matches on this label; without it the rule is created but
# never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: external-dns.rules
rules:
# The signal that was missing on 2026-09-12: the sources stopped
# producing endpoints they had produced before, and policy=sync
# deleted the records. 10% below the 24h peak clears a single
# retired Ingress (2 of ~48) and catches a whole class of names.
- alert: ExternalDNSSourceEndpointsDropped
expr: >-
external_dns_source_endpoints_total
< 0.9 * max_over_time(external_dns_source_endpoints_total[24h])
for: 30m
labels:
severity: critical
annotations:
summary: "{{ $labels.job }} is publishing {{ $value }} endpoints, below its 24h peak"
description: >-
external-dns stopped seeing endpoints it had before. With
policy=sync the matching DNS records are deleted, so names stop
resolving without anything else failing. Compare
external_dns_source_endpoints_total against the last 24h, then
check whether the resources still carry a hostname annotation
the running version understands — a changed annotation prefix
default empties the source silently. Deliberate removals also
fire this and resolve themselves 24h later.
# Source errors mean the Kubernetes side failed; registry errors mean
# the provider did. Either way desired state is computed from partial
# input, which policy=sync acts on.
- alert: ExternalDNSSourceErrors
expr: increase(external_dns_source_errors_total[15m]) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.job }} is failing to read its sources"
description: >-
external-dns could not list the Kubernetes resources it derives
records from. Check the pod log and its RBAC.
- alert: ExternalDNSRegistryErrors
expr: increase(external_dns_registry_errors_total[15m]) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.job }} is failing to reach its DNS provider"
description: >-
external-dns could not read or write records at the provider.
For external-dns-unifi that is the UniFi webhook sidecar and the
controller behind it; for external-dns it is the Cloudflare API.
- alert: ExternalDNSDown
expr: up{job=~"external-dns.*"} == 0
for: 15m
labels:
severity: critical
annotations:
summary: "{{ $labels.job }} is not being scraped"
description: >-
The external-dns instance is down or unreachable. Records already
at the provider keep resolving, but nothing reconciles them any
more and every other alert in this group is blind.
# Dead-man's-switch for the group, mirroring BlackboxProbesAbsent: a
# dropped ServiceMonitor makes every rule above evaluate to nothing.
- alert: ExternalDNSMetricsMissing
expr: absent(external_dns_source_endpoints_total)
for: 15m
labels:
severity: critical
annotations:
summary: "No external-dns metrics are reaching Prometheus"
description: >-
Either both external-dns instances are gone or the ServiceMonitor
stopped being discovered. Verify it still carries
release=kube-prometheus-stack.
@@ -28,24 +28,28 @@ spec:
# scheduling would also fire this — but both want a human too, so the
# imprecision costs nothing.
- alert: OpenBaoReplicaNotReady
# A replica that is sealed never recovers on its own, so the
# duration is only there to ignore ordinary rescheduling: a healthy
# pod moving between nodes is Ready again in well under a minute.
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
# A replica unseals itself from the static seal, so a restart is
# Ready again in well under a minute; the duration only ignores that
# ordinary rescheduling. A replica still sealed after it never
# recovers on its own: the seal failed (openbao-seal-key Secret
# missing or wrong).
# max by: kube-state-metrics stamps instance/pod on the series, so a
# rescheduled exporter starts a new series and resets the timer.
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 3
for: 10m
labels:
severity: warning
annotations:
summary: "OpenBao has {{ $value }} of 3 replicas ready"
description: >-
An OpenBao replica has been unavailable for 10 minutes. The usual
cause is a sealed replica after a node drain — every restart
seals it, and it stays out of the raft cluster until unsealed by
hand. The service is still up on the remaining replicas, so
nothing is broken yet, but redundancy is reduced and a second
drain would cost quorum. Check `kubectl -n openbao get pods` and
`bao status` in the not-ready pod; unseal with the key from
`pass` (homelab/openbao/unseal-key).
An OpenBao replica has been unavailable for 10 minutes. A restart
unseals itself from the static seal, so this is not the ordinary
drain case. The service is still up on the remaining replicas,
so nothing is broken yet, but redundancy is reduced and a second
failure would cost quorum. Check `kubectl -n openbao get pods`,
the pod log and `bao status` in the not-ready pod. If it is
sealed, the static seal failed: verify the openbao-seal-key
Secret matches `pass show homelab/openbao/seal-key`.
- alert: OpenBaoQuorumLost
# Raft quorum of a three-node cluster is two. At one ready replica
@@ -53,11 +57,14 @@ spec:
# the ClusterSecretStore goes InvalidProviderConfig and no
# ExternalSecret can refresh.
#
# Shorter window than the warning above because this is an outage,
# not a degradation — but not zero, so a rolling restart that
# briefly dips does not page.
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
for: 5m
# Short window: readiness is only a proxy for quorum. A one-second
# node NotReady flap makes the node lifecycle controller mark that
# node's pods not-ready for ~30s while raft keeps its leader and
# serves (issue #920, during a rolling reboot with one replica
# already evicted). A really sealed or crashed majority persists,
# and existing Secrets keep their last values, so 2m costs little.
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
for: 2m
labels:
severity: critical
annotations:
@@ -67,9 +74,11 @@ spec:
quorum and the secret store is unavailable. ExternalSecrets stop
refreshing; existing Secrets keep their last values, so failures
surface only when something rotates or a new secret is needed.
Unseal the sealed replicas (key in `pass`,
homelab/openbao/unseal-key) — each one individually, since a
sealed server cannot be a standby.
Replicas unseal themselves from the static seal; one that stays
sealed could not. Check its log and the openbao-seal-key Secret
against `pass show homelab/openbao/seal-key`, fix the Secret and
restart the pod. The recovery key cannot unseal an auto-sealed
server. Procedure: kubernetes/RUNBOOK-cold-rebuild.md, Step 4.
- alert: OpenBaoMetricsMissing
# Guards the two alerts above. Both are threshold comparisons, so if
@@ -0,0 +1,368 @@
{
"title": "Intel iGPU",
"uid": "intel-gpu",
"tags": ["gpu", "intel", "i915"],
"schemaVersion": 39,
"editable": true,
"timezone": "",
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "node",
"label": "Node",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": {
"query": "label_values(gpumon_power, node)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"definition": "label_values(gpumon_power, node)",
"includeAll": true,
"multi": true,
"refresh": 2,
"sort": 1,
"current": { "selected": true, "text": ["All"], "value": ["$__all"] }
}
]
},
"panels": [
{
"type": "stat",
"title": "Render/3D busy now",
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"decimals": 0,
"min": 0,
"max": 100,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "stat",
"title": "GPU power now",
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"decimals": 1,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "stat",
"title": "GT frequency now",
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "suffix: MHz",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "text" }
},
"overrides": []
},
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"textMode": "value_and_name",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"targets": [
{
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Render/3D busy",
"description": "Compute and 3D. llama.cpp's Vulkan backend runs here; 0 % during an inference means the model is on the CPU.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Video busy",
"description": "Fixed-function decode/encode (QuickSync).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Video\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "VideoEnhance busy",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"VideoEnhance\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Blitter busy",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Blitter\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "GT frequency (actual)",
"description": "0 while the GT sits in RC6.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "suffix: MHz",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "GPU power",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Package power",
"description": "Whole SoC (CPU + GPU + uncore), from the same PMU.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "watt",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_power{type=\"pkg\", node=~\"$node\"}",
"legendFormat": "{{node}}",
"refId": "A"
}
]
},
{
"type": "timeseries",
"title": "Memory controller bandwidth",
"description": "Reads solid, writes dashed. The iGPU shares DDR5 with the CPU, so this is the whole package's traffic.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "MiBs",
"min": 0,
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] },
{ "matcher": { "id": "byRegexp", "options": "/write/" }, "properties": [{ "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [10, 10] } }] }
]
},
"options": {
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"targets": [
{
"expr": "gpumon_imc_read{node=~\"$node\"}",
"legendFormat": "{{node}} read",
"refId": "A"
},
{
"expr": "gpumon_imc_write{node=~\"$node\"}",
"legendFormat": "{{node}} write",
"refId": "B"
}
]
}
]
}
@@ -77,6 +77,9 @@ configMapGenerator:
- name: dashboard-infrastructure-overview
files:
- infrastructure-overview.json
- name: dashboard-intel-gpu
files:
- intel-gpu.json
- name: dashboard-netbird-overview
files:
- netbird-overview.json
@@ -232,7 +232,7 @@ metadata:
lb-pool: bgp
annotations:
io.cilium/lb-ipam-ips: "10.2.99.84,fd99::84"
external-dns.alpha.kubernetes.io/hostname: syslog.renner.uno
external-dns.kubernetes.io/hostname: syslog.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
@@ -72,3 +72,73 @@ spec:
tcp:
preferred_ip_protocol: ip6
ip_protocol_fallback: true
# One module per name: the dns prober takes the RESOLVER as its
# target and reads query_name from the module, so a name cannot be
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
# point these at the UDM.
#
# Both answer checks are load-bearing. valid_rcodes alone passes a
# deleted record two labels deep (NOERROR, no answer), and the public
# *.renner.uno wildcard answers one label deep with a CNAME to
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
# an A and rejecting a CNAME separates a real record from both.
dns_lan_ha:
prober: dns
timeout: 5s
dns:
query_name: ha.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
dns_lan_mqtt:
prober: dns
timeout: 5s
dns:
query_name: mqtt.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
dns_lan_ldap:
prober: dns
timeout: 5s
dns:
query_name: ldap.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
dns_lan_syslog:
prober: dns
timeout: 5s
dns:
query_name: syslog.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
dns_lan_ftp_scanner:
prober: dns
timeout: 5s
dns:
query_name: ftp.paperless.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
dns_lan_syncthing:
prober: dns
timeout: 5s
dns:
query_name: syncthing.paperless.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
fail_if_not_matches_regexp: ['\sIN\s+A\s']
fail_if_matches_regexp: ['\sIN\s+CNAME\s']
@@ -91,12 +91,11 @@ spec:
# Additional scrape targets not covered by ServiceMonitors.
additionalScrapeConfigs:
# Gitea exposes /metrics anonymously on its main HTTP port. The
# `gitea_*` metrics power the gitea-overview dashboard. Gitea now runs
# in-cluster (StatefulSet in apps ns; LXC 114 retired), so scrape the
# ClusterIP Service directly instead of the dead LXC hostname.
# `gitea_*` metrics power the gitea-overview dashboard. Scrapes the
# ClusterIP Service of the StatefulSet in the gitea namespace.
- job_name: gitea
static_configs:
- targets: ["gitea.apps.svc.cluster.local:3000"]
- targets: ["gitea.gitea.svc.cluster.local:3000"]
metrics_path: /metrics
# Grafana
@@ -305,6 +304,10 @@ spec:
- matchers:
- alertname = "KubeCPUOvercommit"
receiver: 'null'
# Helper alert that only drives the info inhibit rule below.
- matchers:
- alertname = "InfoInhibitor"
receiver: 'null'
# Flux notification-controller posts info-severity events
# here (e.g. image-automation commits). Keep them visible
# but don't repeat-spam every 12h on the same event.
@@ -314,6 +317,23 @@ spec:
group_wait: 1m
group_interval: 10m
repeat_interval: 24h
# From kube-prometheus manifests/alertmanager-secret.yaml.
inhibit_rules:
- source_matchers:
- severity = "critical"
target_matchers:
- severity =~ "warning|info"
equal: ['namespace', 'alertname']
- source_matchers:
- severity = "warning"
target_matchers:
- severity = "info"
equal: ['namespace', 'alertname']
- source_matchers:
- alertname = "InfoInhibitor"
target_matchers:
- severity = "info"
equal: ['namespace']
receivers:
- name: email
email_configs:
@@ -0,0 +1,81 @@
# Intel iGPU utilisation, frequency and power for every node (#752).
#
# Not privileged: /dev/dri comes from the device plugin's monitoring resource
# (every GPU on the node, no hostPath), and the i915 PMU that intel_gpu_top
# reads needs CAP_PERFMON — the one capability granted. No hostPID, no
# hostNetwork.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
selector:
matchLabels:
app.kubernetes.io/name: intel-gpu-exporter
template:
metadata:
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
# Every node is a control plane; without this the exporter covers nothing.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: intel-gpu-exporter
image: ghcr.io/clambin/intel-gpu-exporter:0.7.4@sha256:d78761fef018834c759db154fe9dcf3dd9db5974703fa50ca21f6abc35221be1
args:
# No -device: intel_gpu_top picks the only GPU the plugin injects.
- -interval=5s
ports:
- name: http-metrics
containerPort: 9100
protocol: TCP
securityContext:
# Root so the PERFMON capability is effective without file
# capabilities on the binary.
runAsUser: 0
runAsNonRoot: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["PERFMON"]
resources:
requests:
cpu: 10m
memory: 32Mi
gpu.intel.com/monitoring: 1
limits:
memory: 128Mi
gpu.intel.com/monitoring: 1
livenessProbe:
httpGet:
path: /metrics
port: http-metrics
initialDelaySeconds: 15
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: intel-gpu-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: intel-gpu-exporter
spec:
clusterIP: None
selector:
app.kubernetes.io/name: intel-gpu-exporter
ports:
- name: http-metrics
port: 9100
targetPort: http-metrics
protocol: TCP
@@ -16,6 +16,7 @@ resources:
- prometheus-gate-ingress.yaml
- ci-diagnostics-rbac.yaml
- smartctl-exporter.yaml
- intel-gpu-exporter.yaml
- dashboards/
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
@@ -28,7 +28,7 @@ spec:
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
# worker, though it is no longer the same kind of device everywhere:
# on the bare-metal control-plane nodes it is a VLAN 30 sub-interface
# of enp2s0f0np0 rather than a NIC, which is why the datapath needs
# of uplink rather than a NIC, which is why the datapath needs
# bpf.vlanBypass to carry it (issue #592).
k8s.v1.cni.cncf.io/networks: music-assistant-iot
spec:
@@ -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.2 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.4 # {"$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.2 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.4 # {"$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