Compare commits

...
Author SHA1 Message Date
k.renner 532b853929 Merge pull request 'fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN' (#1459) from fix/ma-sendspin-mdns-net1 into main
Reviewed-on: #1459
2026-10-26 14:34:12 +00:00
claude-botandClaude Opus 5.5 af7103fd75 fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
aiosendspin starts its zeroconf on [bind_ip], or on InterfaceChoice.Default
when bind_ip is 0.0.0.0. Default egresses via the pod's main-table default
route (eth0); net1 routes sit in the sbr table. MA therefore never sent
_sendspin._tcp queries onto v30iot and only rediscovered Voice PEs when they
announced on their own, leaving them disconnected for hours after every MA
restart (e.g. 2026-10-26 13:56: 3 of 4 still offline at 15:05).

The reconcile initContainer now writes bind_ip = the live net1 address it
already computes for publish_ip. The address is read from the interface on
every boot, so the Sendspin no-bind-fallback failure (stale literal IP)
cannot recur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:12:32 +01:00
k.renner 1984b01e5d Merge pull request 'fix(music-assistant): mark Up Next episodes played from the playlist' (#1458) from claude/ma-pocketcasts-up-next-mark-played into main
Reviewed-on: #1458
2026-10-26 12:53:42 +00:00
claude-botandClaude Opus 5.5 e02ad7639b fix(music-assistant): mark Up Next episodes played from the playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Up Next payload carries no episode duration. Marking an episode played
in the UI reports the duration as the position, so the provider received 0
and took it for an explicit "mark unplayed": the episode stayed in Up Next
and was flagged unplayed.

The Up Next playlist now takes duration and playback status from the
(cached, throttled) episode details, falling back to the bare entry when
the details cannot be fetched.

Tested against the 2.10.7 source: 21/21 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:51:27 +01:00
k.renner 80f5310653 Merge pull request 'feat(music-assistant): Pocket Casts Up Next as a library playlist' (#1457) from claude/ma-pocketcasts-up-next-playlist into main
Reviewed-on: #1457
2026-10-26 12:30:42 +00:00
claude-botandClaude Opus 5.5 9895b7f4f4 feat(music-assistant): Pocket Casts Up Next as a library playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Overlays the Pocket Casts provider with a patched __init__.py that adds
ProviderFeature.LIBRARY_PLAYLISTS and a read-only "Up Next" playlist of
podcast episodes, fetched live from /up_next/list in queue order.

An initContainer copies the image's provider into an emptyDir and swaps
in the patch only when the stock file's sha256 matches the version it was
written against (identical in 2.10.7 and 2.10.8). A later image bump that
changes the provider keeps the stock provider instead of breaking it.

Temporary until the change is upstreamed to music-assistant/server.
Tested against the 2.10.7 source: 18/18 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:44 +01:00
k.renner c380c7c2b8 Merge pull request 'revert(music-assistant): remove PO token server' (#1456) from revert/ma-po-token-server into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1456
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 10:18:28 +00:00
claude-botandClaude Opus 5.5 0f86537bb1 revert(music-assistant): remove PO token server
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Reverts #1451. The YouTube Music provider is not kept; the PO token
server and its image automation go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:11 +01:00
security-bot 31459dac6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793006975
Updates managed by image-automation-controller.
2026-10-26 09:36:48 +00:00
k.renner 57c96a9a62 Merge pull request 'fix(image-automation): unpin the gitea-mcp ImageRepository' (#1452) from claude/gitea-mcp-imagerepo into main
Reviewed-on: #1452
2026-10-26 09:28:43 +00:00
claude-botandClaude Opus 5.5 ffd24865ee fix(image-automation): unpin the gitea-mcp ImageRepository
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate #1439 pinned a digest into the ImageRepository's spec.image,
which image-reflector rejects ("should not include a tag"), so Flux
stopped tracking gitea-mcp-server. The image was missing from the
renovate.json list of images Flux image automation already bumps; add
it there so Renovate leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:11:42 +01:00
k.renner 439e4b4b83 Merge pull request 'feat(music-assistant): PO token server for YouTube Music provider' (#1451) from feat/ma-youtube-music-po-token-v2 into main
Reviewed-on: #1451
2026-10-26 09:05:42 +00:00
k.renner 622a4cc593 Merge pull request 'chore(coredns): Talos alone owns the Corefile (2/2)' (#1450) from claude/coredns-talos-owned-2 into main
Reviewed-on: #1450
2026-10-26 09:04:47 +00:00
claude-botandClaude Opus 5.5 96ea510143 feat(music-assistant): add PO token server for YouTube Music provider
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
MA's YouTube Music provider needs a bgutil PO token server; MA installs
the matching yt-dlp plugin unpinned from PyPI (2.0.2 today), so the
server tracks 2.x via image automation. The MA docs' "1.2.1" predates
the 2.x plugin line.

Reachable only from the MA pod; egress limited to DNS and world:443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:02:41 +01:00
claude-botandClaude Opus 5.5 60d3acc202 chore(coredns): Talos alone owns the Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 2 of 2. The coredns ConfigMap is a Talos bootstrap manifest; Flux
stops applying its own copy (prune is disabled since the previous
commit, so the live ConfigMap stays until talosctl upgrade-k8s writes
Talos's version). CoreDNS forwards to the node resolver, which asks
k8s-gateway first, so cluster DNS no longer depends on Flux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:37 +01:00
k.renner cb697e2812 Merge pull request 'chore(coredns): keep Flux from pruning the Talos-owned Corefile (1/2)' (#1449) from claude/coredns-talos-owned into main
Reviewed-on: #1449
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 08:58:29 +00:00
claude-botandClaude Opus 5.5 1e26d5cdb2 chore(coredns): keep Flux from pruning the Talos-owned Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 1 of 2 to hand the coredns ConfigMap back to Talos. It is a Talos
bootstrap manifest (talos-bootstrap-manifests-inventory) that Flux also
applies; talosctl upgrade-k8s reverts Flux's Corefile. Bootstrap already
runs on Talos's Corefile (forward to the node resolver, which asks
k8s-gateway first), and k8s-gateway carries serve_stale for every name,
so the Flux copy is redundant.

With prune disabled, step 2 can drop the file without Flux deleting the
live ConfigMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:55:13 +01:00
k.renner fbd3fbd119 Merge pull request 'fix(dns): pods ask the UDM on the segment they leave on' (#1448) from claude/k8s-gateway-ingress-resolver into main
Reviewed-on: #1448
2026-10-26 07:39:52 +00:00
k.renner 3c05098780 Merge pull request 'fix(egress): syncthing STUN 3479, Loki usage reporting off' (#1447) from claude/egress-followups into main
Reviewed-on: #1447
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 07:39:25 +00:00
claude-botandClaude Opus 5.5 5ecd17d26b fix(dns): pods ask the UDM on the segment they leave on
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since UniFi OS 6.0.15 / Network 11.0.97 (2026-10-25 18:42Z) the UDM's
DNS answers out of the interface a query arrived on. Pod traffic to
10.2.40.1 arrives on VLAN 40 (on-link route in table 47) while the pod
CIDRs are routed back over VLAN 47, so the UDM ARPs for the pod on
VLAN 40 and never answers (seen with talosctl pcap on k8s-3; TCP 443 to
the same address works, UDP and TCP 53 do not). fd00::1 was reached
over VLAN 47 and kept working.

k8s-gateway's upstream (k8s-gateway-site), the blackbox-dns probes and
the gatus UDM check now use the UDM's ingress-segment addresses,
fd02:47::1 and 10.2.47.1 (the egress-gw annotations); both answer from
a pod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:29:04 +01:00
security-bot f29e9f965a chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792997373
Updates managed by image-automation-controller.
2026-10-26 06:56:47 +00:00
security-bot 42a3a31460 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1792997278 gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647
Updates managed by image-automation-controller.
2026-10-26 06:53:44 +00:00
security-bot 188784d406 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26
Updates managed by image-automation-controller.
2026-10-26 06:53:42 +00:00
security-bot 6f5c4f7b81 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-26 06:52:51 +00:00
claude-botandClaude Opus 5.5 76f3d4b6b5 fix(egress): syncthing STUN 3479, Loki usage reporting off
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing's STUN NAT-type probe sends its change request to port 3479,
which syncthing-egress dropped (seen: 77.72.169.212:3479/UDP).

Loki tried to send usage reports to Grafana (34.49.170.206:443), denied
by its egress policy; switch reporting off instead of allowing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:51:31 +01:00
k.renner 67ff176875 Merge pull request 'chore(deps): update alpine:3.24 docker digest to be26560' (#1441) from renovate/multus into main
Reviewed-on: #1441
2026-10-26 06:43:16 +00:00
k.renner 2ddb9544e6 Merge pull request 'feat(wiki): sync and encyclopedia schedules as CronJobs' (#1446) from feat/wiki-cronjobs into main
Reviewed-on: #1446
2026-10-26 06:43:02 +00:00
k.renner 25e70dc555 Merge pull request 'chore(deps): update home-assistant non-major' (#1442) from renovate/home-assistant-minor-patch into main
Reviewed-on: #1442
2026-10-26 06:42:36 +00:00
k.renner a4b18f44c3 Merge pull request 'chore(deps): update all non-major dependencies' (#1440) from renovate/all-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1440
2026-10-26 06:42:16 +00:00
k.renner 49856fe577 Merge pull request 'chore(deps): update influxdb:2.9.1 docker digest to 8f106f7' (#1443) from renovate/influxdb into main
Reviewed-on: #1443
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:42:01 +00:00
k.renner 576611faae Merge pull request 'chore(deps): update ci non-major' (#1444) from renovate/ci-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/ci-claude-refresh Pipeline is pending
ci/woodpecker/push/deploy-infrastructure Pipeline is pending
Reviewed-on: #1444
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:41:44 +00:00
k.renner cc898ec553 Merge pull request 'chore(deps): update hermes image' (#1445) from renovate/hermes-image into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
Reviewed-on: #1445
2026-10-26 06:41:25 +00:00
k.renner 7d53c99062 Merge pull request 'chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566' (#1439) from renovate/pin-image-digests into main
Reviewed-on: #1439
2026-10-26 06:40:47 +00:00
claude-botandClaude Opus 5.5 8ce5ebd8ac feat(wiki): sync and encyclopedia schedules as CronJobs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wiki-sync (every 30 min) and wiki-encyclopedia (06:00 Europe/Berlin) call
the service's API with a generated scheduler key; the service no longer
schedules anything itself (konairius/Wiki#18). Pods in ai already reach
the wiki through the namespace default policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:37:15 +01:00
renovate-bot 9258947fef chore(deps): update hermes image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-26 03:01:35 +00:00
renovate-bot 1955a11b81 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:34 +00:00
renovate-bot da614f83fe chore(deps): update influxdb:2.9.1 docker digest to 8f106f7
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:30 +00:00
renovate-bot e5c99655e5 chore(deps): update home-assistant non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:27 +00:00
renovate-bot 392a010daa chore(deps): update alpine:3.24 docker digest to be26560
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:25 +00:00
renovate-bot f04f449163 chore(deps): update all non-major dependencies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:23 +00:00
renovate-bot 32d0f0cc7b chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:19 +00:00
security-bot c79ccfb3f3 chore(image-automation): bump lscr.io/linuxserver/lidarr:3.1.6.5079-ls46
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-10-26 02:52:46 +00:00
k.renner 0904f73721 Merge pull request 'fix(netpol): allow shelly-manager mDNS queries' (#1436) from claude/shelly-manager-mdns into main
Reviewed-on: #1436
2026-10-25 22:16:39 +00:00
k.renner ef2024c565 Merge pull request 'feat(mqtt): admin login as a register account' (#1437) from claude/mqtt-admin-register into main
Reviewed-on: #1437
2026-10-25 22:16:23 +00:00
claude-botandClaude Opus 5.5 3c86f0970c feat(mqtt): admin login as a register account
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The controller (main-b1f294d, konairius/mqtt-account-controller#13)
delivers a password account to a namespace Secret. admin moves into the
register with its password in Secret mqtt-admin-password; Dynamic
Security is asked first, so it replaces the password_file login without
a broker restart. The controller gets get/create/patch/delete on that
Secret by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:06:03 +01:00
security-bot 4c5665aa75 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-25 gitea.renner.uno/gitea_admin/review-bot:2026-10-25-1455 gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25
Updates managed by image-automation-controller.
2026-10-25 15:23:41 +00:00
security-bot 38091cbe11 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459
Updates managed by image-automation-controller.
2026-10-25 15:23:40 +00:00
security-bot 33c0266fc1 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504
Updates managed by image-automation-controller.
2026-10-25 15:17:02 +00:00
claude-botandClaude Opus 5.5 9bcc1cb420 fix(netpol): allow shelly-manager mDNS queries
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager resolves device *.local names via mDNS; the queries leave on
eth0 to 224.0.0.251:5353 and have been dropped since the egress lockdown
(#1432), failing with 'Name or service not known' since 15:07Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:58 +01:00
security-bot b10b50fd2d chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792940452
Updates managed by image-automation-controller.
2026-10-25 15:06:45 +00:00
security-bot f9c517bd37 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458
Updates managed by image-automation-controller.
2026-10-25 15:06:43 +00:00
security-bot 49cafb2f6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792939775
Updates managed by image-automation-controller.
2026-10-25 14:56:41 +00:00
k.renner 6fa10a7744 Merge pull request 'feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning' (#1435) from feat/wiki-encyclopedia-pilot into main
Reviewed-on: #1435
2026-10-25 14:51:08 +00:00
k.renner b341b26673 Merge pull request 'feat(netpol): restrict pod egress to the outside world per workload' (#1432) from claude/egress-lockdown into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1432
2026-10-25 14:50:32 +00:00
k.renner aad24d4186 Merge pull request 'feat(mosquitto): retire legacy password users (admin stays, generated Secret)' (#1433) from claude/mosquitto-legacy-auth-removal into main
Reviewed-on: #1433
2026-10-25 14:50:09 +00:00
k.renner 7ef4475952 Merge pull request 'chore(ci): bump ci-claude image to 2.1.310' (#1434) from ci-image/claude-2.1.310 into main
Reviewed-on: #1434
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-25 14:48:49 +00:00
claude-botandClaude Opus 5.5 aca39a5a8f feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Only members of the new Authentik group Wiki Readers may sign in to the wiki
OIDC application; membership is managed in Authentik, so the owner must be
added before merge. Encyclopedia is enabled with MaxArticles=0 so the first
nightly run only plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:52:51 +01:00
claude-bot d7ca8dda11 chore(ci): bump ci-claude image to 2.1.310
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-25 00:01:21 +00:00
security-bot c8483b3545 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.4
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-24 20:50:33 +00:00
claude-botandClaude Opus 5.5 bb090e6809 feat(mosquitto): retire legacy password users; admin stays on a generated Secret
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since the broker restart on 2026-10-22 only register accounts connect; the
last legacy logins (homeassistant, sml2mqtt, open3e, zigbee2mqtt) were on
2026-10-08, and admin/evcc never logged in within the 30-day Loki window.

- Delete mosquitto-bootstrap.yaml (5 Jobs that apk add at runtime + RBAC).
- The broker's password_file/acl_file now hold only admin, the owner's
  workstation login over the LoadBalancer. The controller has no delivery
  mode that puts a password account's password into a Secret, and the cert
  listener :8884 is in-cluster only, so admin cannot be a register account.
  Its password comes from an ESO Password generator (CreatedOnce), the
  pattern of apps/hermes/api-key.yaml, in Secret mosquitto-admin-password.
- Delete the evcc and health ExternalSecrets and the health rotator
  CronJob/policy; nothing reads them.
- Delete PROPOSAL-mosquitto-credential-rotation.md; its subject is gone.
- MosquittoDown test masks with the dynsec-test pod instead of a
  bootstrap Job pod; comments and docs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:46:00 +02:00
claude-botandClaude Opus 5.5 5aee6a54ad revert(netpol): no world egress for mosquitto-bootstrap
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Runtime package installs are not to be enabled by policy. The Jobs feed only
the unused legacy password_file and are removed in a separate PR, which must
merge before this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:33:25 +02:00
claude-botandClaude Opus 5.5 8a53171ad0 fix(netpol): let mosquitto-bootstrap Jobs reach Alpine mirrors
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Jobs run apk add curl jq on every execution; without world 443/80 they
fail and block the home-assistant Kustomization. Also correct the stale
renderer egress comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:49 +02:00
claude-botandClaude Opus 5.5 299194247e feat(netpol): allow mqtt-account-controller to deliver to edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
edge-1.iot.renner.uno:8443 is the deliver.esp32 target in mqtt-accounts.yaml
(https+mTLS credential push). Matched by FQDN, not the current 10.2.30.108.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:00 +02:00
claude-bot 6b6e1e7e04 Merge remote-tracking branch 'origin/main' into worktree-egress-lockdown
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/infrastructure/network-policies/gitea-ns.yaml
#	kubernetes/infrastructure/network-policies/matrix-ns.yaml
2026-10-24 12:45:03 +02:00
claude-botandClaude Opus 5.5 752fcd5d03 feat(netpol): restrict pod egress to the outside world per workload
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Drop namespace-wide world egress and give each workload only the external
destinations it needs (FQDN where the host is in our config, port-scoped
world otherwise). Namespaces that had ingress-only policies get an
in-cluster + DNS egress baseline. CNPG instances get Hetzner S3 via one
clusterwide policy.

Out of scope: kube-system, flux-system, rook-ceph, woodpecker-ci,
music-assistant, evcc, openbao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:43:38 +02:00
security-bot 55cd083484 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.37.2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 20:49:32 +00:00
security-bot ada3d3d0b4 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.3
Updates managed by image-automation-controller.
2026-10-23 19:50:03 +00:00
security-bot 1bd3111912 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.8
Updates managed by image-automation-controller.
2026-10-23 14:49:29 +00:00
security-bot 0e12e90509 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 08:02:02 +00:00
k.renner 613e1da011 Merge pull request 'fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL' (#1430) from fix/gate-embedded-browser-host into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1430
2026-10-23 07:57:11 +00:00
117 changed files with 2304 additions and 1216 deletions
+1 -1
View File
@@ -319,7 +319,7 @@ steps:
# writes its stripped mirror under mktemp and never into the workspace.
- name: promtool
depends_on: [] # see note above `steps:`
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
commands:
- sh scripts/ci/promtool-rules.sh
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+3 -3
View File
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy image --download-db-only
- |
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
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.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+13 -14
View File
@@ -72,14 +72,14 @@ none of these commands.
## Secrets (before first play run)
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
current live values come straight out of the cluster:
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
cluster broker has no `open3e` user: edge-1 logs in only as the register
account `edge-1` (see "MQTT bridge" below), so
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
allows anonymous clients; any random value works (`openssl rand -hex 24`).
The zwave-js value comes out of the cluster:
```
# sml2mqtt broker password
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
# open3e broker password
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
# zwave-js session secret
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
```
@@ -230,15 +230,14 @@ They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
hard-depends on cluster/network uptime for every publish.
Two bridge `connection`s preserve the existing cluster identities, so **no
cluster-side change was needed**: one authenticates as the `open3e` user
(carries `open3e/#` out + `open3e/cmnd` back), one as `sml2mqtt` (`sml2mqtt/#`
out). The local broker is `allow_anonymous` on loopback — the trust boundary
is the host; nothing off-host can reach it.
The local broker is `allow_anonymous` on loopback — the trust boundary is the
host; nothing off-host can reach it.
**Bridge credential (REN-1307).** The two vault-password bridges are being
replaced by one bridge as the register account `edge-1`
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
until the first push. Its password is pushed
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
esp32 API over HTTPS). That endpoint accepts only a client certificate from
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
# workload that needs to read JSON from a shell script.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
ansible \
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+2 -2
View File
@@ -1,6 +1,6 @@
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
bash \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -28,7 +28,7 @@
# amd64 only, matching the ARCH the installer already hardcoded and the cluster's
# node architecture. Add a second stage if a different arch is ever needed.
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS fetch
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS fetch
ARG CNI_PLUGINS_VERSION=v1.9.1
ARG CNI_PLUGINS_SHA256=b98f74a0f8522f0a83867178729c1aa70f2158f90c45a2ca8fa791db1c76b303
@@ -61,7 +61,7 @@ RUN set -eu; \
[ -x "/plugins/$p" ] || { echo "FATAL: /plugins/$p missing or not executable" >&2; exit 1; }; \
done
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine rather than scratch: the installer needs a shell to copy the binaries
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS build
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -1,4 +1,4 @@
FROM nousresearch/hermes-agent:v0.22.2@sha256:36099926da316742423544e055d4734777105ffe6341873af5459b957d0c2919
FROM nousresearch/hermes-agent:v0.22.5@sha256:9c5561579f3ffdf333401797094ac3a743738d6c25d40f4331c7bf77b694238d
USER root
@@ -6,9 +6,9 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.13.10@sha256:3116f64c97ddffb53bd3c5bf87c8614d99f04df4326177dbbb7702b5087f0e4d /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.36.3@sha256:dae261106d5555008acede256b7102c8a6decde7209198434f5999f051460f57 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.5@sha256:704d5529535570a495249b643159c93a9aa88ffca07813c974a54b7ba6edbd86 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG TALOS_VERSION=v1.13.12
ARG VELERO_VERSION=v1.16.0
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG TOFU_VERSION=1.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
# the cluster instead of on a workstation. The workstation has 31G and the test
@@ -48,7 +48,7 @@ spec:
# cert when doing server-side OIDC token exchange. Bundle both
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
- name: fetch-le-staging-roots
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
command:
- sh
- -c
@@ -70,7 +70,7 @@ spec:
mountPath: /ca
containers:
- name: audiobookshelf
image: ghcr.io/advplyr/audiobookshelf:2.37.1 # {"$imagepolicy": "flux-system:audiobookshelf"}
image: ghcr.io/advplyr/audiobookshelf:2.37.2 # {"$imagepolicy": "flux-system:audiobookshelf"}
ports:
- name: http
containerPort: 80
@@ -1034,9 +1034,19 @@ data:
negate: false
failure_result: false
# wiki.renner.uno signs in via OIDC application `wiki`; only this group may sign in.
# Members are managed in Authentik.
- id: wiki-readers-group
model: authentik_core.group
identifiers:
name: Wiki Readers
state: present
attrs:
is_superuser: false
# Wiki (apps/wiki): its own OIDC login. Public client + PKCE; RS256
# so the service verifies the ID token against the JWKS. No policy
# binding: every Authentik user may sign in.
# so the service verifies the ID token against the JWKS. Only the
# Wiki Readers group passes.
- id: wiki-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
@@ -1072,6 +1082,17 @@ data:
meta_launch_url: https://wiki.renner.uno
meta_description: "Homelab wiki"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, wiki]]
group: !KeyOf wiki-readers-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenProject, decommissioned. Application first: Application.provider
# is on_delete=PROTECT. Its policy binding cascades with it.
- model: authentik_core.application
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0748 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.317.1@sha256:c50cd4f83c22b39722a76a76b8d428b69bf8ab3334209ed9ed24ec0e3c48dde5
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
# evcc auto-discovers /etc/evcc.yaml on its default search path;
# the file is delivered (with the MQTT password) via the
# sops-encrypted evcc-config Secret mounted below.
+4 -2
View File
@@ -20,7 +20,7 @@ metadata:
# services group infrastructure group
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.47.1)
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
# Web Service 5 = Grafana
# Web Service 6 = Home Assistant
@@ -290,9 +290,11 @@ data:
# a backup server that nothing backs up to reports on nothing. Re-add
# alongside the first host that is declared to back up to it.
# The UDM on the segment pod traffic leaves on (k8s-gateway-site in
# talos/patches/site-homelab.yaml).
- name: Infrastructure 3
group: infrastructure
url: 10.2.40.1
url: 10.2.47.1
interval: 30s
dns:
query-name: udm.renner.uno
+1 -1
View File
@@ -115,7 +115,7 @@ spec:
type: RuntimeDefault
containers:
- name: retention
image: docker.io/library/python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
image: docker.io/library/python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
command: ["python3", "/app/package-retention.py"]
env:
# In-cluster Service; the public name hairpins off the ingress
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/python
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
+10 -10
View File
@@ -51,7 +51,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -75,7 +75,7 @@ spec:
mountPath: /opt/data
# WebSocket keepalive ping interval in seconds (default 20).
- name: config-ws-ping
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -99,7 +99,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -130,7 +130,7 @@ spec:
# - alert-monitor: hourly; the script wakes the agent only for
# untriaged alerts.
- name: cron-jobs
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
# bump logs a warning instead of keeping Hermes from starting.
command:
@@ -187,7 +187,7 @@ spec:
readOnly: true
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
- name: config-mcp-homeassistant
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -214,7 +214,7 @@ spec:
# skills, the Home Assistant MCP and the tea login are re-applied on
# every start. default gets the kanban toolset to hand cards to them.
- name: experts
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a broken expert logs a warning.
command:
- sh
@@ -305,7 +305,7 @@ spec:
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
# completion shell hook plus its allowlist entry on every profile.
- name: gitea-issues
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a failure logs a warning.
command:
- sh
@@ -368,7 +368,7 @@ spec:
readOnly: true
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
- name: default-env
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
# Fatal on a writer failure: without these Matrix and the API server stay down.
command:
- /opt/hermes/.venv/bin/python
@@ -428,7 +428,7 @@ spec:
# tea login hermes-bot, written from the mounted token into an
# emptyDir at the agent's ~/.config/tea.
- name: tea-login
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1792569647 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -88,7 +88,7 @@ spec:
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -130,7 +130,7 @@ spec:
# depends on the kernel's bindv6only default rather than on anything
# stated here, so the roles are made explicit instead of inherited.
- name: socat-ipv6
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -61,7 +61,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/k.renner/shelly-manager:build.1792387459 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/k.renner/shelly-manager:build.1792997278 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -177,7 +177,7 @@ spec:
# No probes: it only serves the API-key path, and a probe failure would
# also pull the pod out of the shelly-manager Service.
- name: api-proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
- name: POD_IP
valueFrom:
@@ -1,179 +0,0 @@
# Proposal: enrol the mosquitto broker credentials in Class-A rotation
**Status:** proposal, nothing implemented.
**Scope:** the 8 MQTT broker users and the credentials behind them.
## Problem
Every self-generated secret in this cluster rotates automatically except these.
`kubernetes/infrastructure/openbao/rotation/` holds 13 manifests defining 15
rotator CronJobs (`home-assistant.yaml` alone defines three). Five are **Class-A**
— authentik, crowdsec, paperless, shelly-manager and woodpecker — meaning
"self-generated random secret", which is exactly what a broker password is.
The mosquitto credentials are the only Class-A-shaped secrets not enrolled.
They are currently minted once by seven near-identical bootstrap Jobs
(`mosquitto-bootstrap.yaml`, 508 lines — the ServiceAccount, Role and RoleBinding
are inside that same file) which generate a password, hand-build Secret JSON with
`jq` and POST it to the Kubernetes API with `curl`. Once created they never
change again.
Three consequences:
1. **They never rotate.** Not on a schedule, not on compromise, not ever.
2. **They exist only in the Secret object** — not in OpenBao, so not covered by
the OpenBao snapshot CronJob, and unrecoverable if lost.
3. **The Jobs cannot be modified.** A Job's `spec.template` is immutable, so
changing even the container image makes the whole `home-assistant`
Kustomization fail to reconcile with `field is immutable`. Any edit requires
deleting or renaming the Jobs.
## Why the obvious fixes do not work
Recorded because each was tried and rejected on evidence, and the reasons are
not obvious from the code.
| Approach | Why it fails |
| --- | --- |
| ExternalSecrets reading hand-seeded OpenBao paths | Depends on content copied in by hand. Git cannot recreate it; a fresh OpenBao leaves the paths empty and ESO then overwrites live broker credentials with nothing. |
| ESO `Password` generator | **Overwrites pre-existing Secrets.** Verified: a Secret holding `KNOWN-PREEXISTING-VALUE` was replaced with a freshly generated password on first sync. Adopting it rotates the 7 Job-minted credentials at once, uncoordinated (`evcc` is already ESO-extract-backed and so unaffected). |
| Keep the Jobs, fix only the image | Blocked by `spec.template` immutability. Renaming the Jobs works but is a hack that recurs on every future edit. |
The common thread: each treats the credential as something to *place*, when the
real requirement is a credential that can be *replaced*.
## How rotation actually has to work here
The broker rebuilds its password file from scratch on **every pod start**
(`mosquitto-deployment.yaml`): `rm -f /mosquitto/auth/passwd` followed by
`mosquitto_passwd -c -b …` for each of the 8 users, sourced from env vars backed
by the 8 Secrets.
Two things follow, and they shape the whole design:
- **A Secret change has no effect until the broker restarts.** There is no
Reloader annotation on the mosquitto Deployment today, unlike the consumers of
the existing Class-A rotators.
- **There is no make-before-break.** A mosquitto user has exactly one password.
The LLAT rotators can mint a second token and revoke the first afterwards; a
broker user cannot hold old and new simultaneously. Unless a transitional
second user is created, broker and client must cut over together.
### Who holds each credential
This is the part that determines cutover cost, and it is not visible from the
manifests alone.
| Broker user | Held by | Cutover |
| --- | --- | --- |
| `health` | the broker's own liveness/readiness probes | in-cluster, same pod restart |
| `zigbee2mqtt` | zigbee2mqtt Deployment (`secretKeyRef`) | in-cluster, pod restart |
| `evcc` | evcc app; already OpenBao-backed via ESO | in-cluster, pod restart |
| `sml2mqtt` | edge host, from `vault_edge_sml2mqtt_mqtt_password` | re-run the edge-host play |
| `open3e` | edge host, from `vault_edge_open3e_mqtt_password` | re-run the edge-host play |
| `homeassistant` | HA MQTT integration — a config entry created in the UI | needs an API/WebSocket update, or a manual re-entry |
| `shellies` | Shelly device firmware | pushed per-device; `shelly-manager` is deployed and is the natural mechanism |
| `admin` | a workstation client, held by a human | read the new value from OpenBao |
Note `sml2mqtt` and `open3e` currently keep **the same password in two unlinked
places** — the Kubernetes Secret for the broker, and `ansible-vault` for the
client — synchronised by hand. That duplication, not any client limitation, is
what makes rotation feel impossible today.
## Proposed design
Follow the existing Class-A pattern exactly (`rotation/paperless.yaml` is the
closest model): *generate random bytes → merge-patch into OpenBao → ESO syncs the
Secret → restart the consumer*.
1. **One rotator CronJob per broker user**, running the existing `secret-rotator`
binary in random mode, writing `apps/home-assistant/mosquitto-<user>-credentials`
in OpenBao. Reuses the established policy/role/SA pattern; no new machinery.
2. **ExternalSecrets** project each OpenBao entry to the existing Secret name and
key, so `mosquitto-deployment.yaml` and the in-cluster consumers are unchanged.
Key names stay as they are (`homeassistant-password` on one, `password` on the
rest) — consumers reference them, so normalising is a separate change.
3. **Add a Reloader annotation to the mosquitto Deployment** so a Secret change
restarts the broker, instead of silently taking effect at some unrelated future
restart.
4. **Move the edge host off `ansible-vault` for these two values**, reading the
same OpenBao entry instead, so there is one source of truth per credential.
5. **Rotate one user at a time.** Each rotation then affects exactly one client,
not all eight.
### The cutover window
With no make-before-break, each rotation has a window where broker and client
disagree. Two options:
- **Accept a brief window** (recommended for most users). Order: rotator writes
OpenBao → client picks up the new value → broker restarts. MQTT clients
reconnect automatically; the window is seconds. Because rotation is per-user,
only that one client is affected.
One failure mode to expect rather than be alarmed by: the broker rolls with
`maxUnavailable: 0` / `maxSurge: 1`, so mid-rollout **two broker pods with
different passwd files sit behind the LB**. A client reconnecting during that
window can land on the old pod and fail auth until the rollout completes. It is
self-healing and lasts seconds to a minute, but it will look alarming in logs
during the first `health` / `zigbee2mqtt` rotation, so it is worth knowing in
advance.
- **Transitional dual user** (`<user>` and `<user>-next` in the passwd file and
ACL) for any credential where even a brief outage is unacceptable. More moving
parts; probably only worth it for `homeassistant`, which carries everything.
## Suggested phasing
Ordered so the risky work happens after the pattern is proven, and so each phase
is independently valuable.
1. **`health` and `zigbee2mqtt` first.** Both are entirely in-cluster and pick up
a new password on pod restart with no external coordination. Proves rotator →
OpenBao → ESO → Reloader end to end with the smallest blast radius. `health`
is the safest possible start: only the broker's own probes consume it.
2. **`evcc`.** Already OpenBao-backed via ESO; only the rotator is missing.
3. **`sml2mqtt` and `open3e`**, together with switching the edge host to read from
OpenBao. This is where the duplicate-source-of-truth problem gets fixed.
4. **`shellies`**, via shelly-manager pushing to devices. Needs investigation:
shelly-manager has its own Class-A rotator today, but that rotates
shelly-manager's own secret and does not touch device MQTT credentials.
5. **`homeassistant`** last. Highest blast radius, and the config entry is UI-created,
so it needs an API/WebSocket update path — the HA LLAT rotators
(`rotation/home-assistant.yaml`) already talk to HA over WebSocket and are the
obvious precedent.
6. **`admin`** whenever convenient; a human reads the new value from OpenBao.
Only once a user is enrolled does its bootstrap Job become dead code. Delete the
Jobs incrementally as their users migrate, rather than in one step — that also
avoids the immutability problem entirely, since the Jobs are deleted, never
patched.
## Open questions
- **Does `secret-rotator` random mode fit unchanged?** The existing rotators write
a field into an OpenBao entry; here the entry holds a single password field, so
it should, but this needs confirming against the binary rather than assumed.
- **Can shelly-manager push MQTT credentials to devices**, and does it cover both
Gen1 (`/settings/mqtt`) and Gen2+ (`Mqtt.SetConfig`) firmwares? If not, the
`shellies` user may need a different mechanism or stay manual longest.
- **How is the HA MQTT config entry updated programmatically?** Reusing the
WebSocket approach from the LLAT rotator is plausible but unverified.
- **Rotation cadence.** The existing rotators' schedules should be the reference
rather than inventing a new one.
- **ESO refresh latency.** Rotator-writes-OpenBao → Secret-updates →
Reloader-restarts is delayed by up to one `refreshInterval`; the `evcc`
ExternalSecret uses `1h`. That does not widen the outage window — broker and
client still move together — but it does stretch how long a rotation takes to
land, which matters for verifying one completed and for the cold-rebuild
ordering question above. Worth deciding whether these ExternalSecrets want a
shorter interval than 1h, or whether the rotator should trigger the sync
directly.
- **Bootstrap on a cold rebuild.** On a fresh cluster the rotator must create the
OpenBao entry before ESO can sync it; check this against
`kubernetes/RUNBOOK-cold-rebuild.md` so broker bring-up is not blocked.
## What this does not change
- Secret names, key names and every `secretKeyRef` in `mosquitto-deployment.yaml`.
- The ACL model and the per-user topic scoping.
- The `evcc` ExternalSecret, which already follows the target pattern.
@@ -128,7 +128,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -192,7 +192,7 @@ spec:
# substitution to POD_IP so nginx-runtime variables like $host
# / $http_x_forwarded_for in the template stay intact.
- name: proxy
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:378d0c32f6562f7fd9629170797f57d2b953aa09be983b3daaf4809bb039ca00
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
- name: POD_IP
valueFrom:
@@ -236,7 +236,7 @@ spec:
# readiness probe on purpose: a broker outage must not take HA out of
# its Service endpoints.
- name: mqtt-tls
image: ghostunnel/ghostunnel:v1.11.4@sha256:e5248233863aadd36383a11c92360f2981141881ef05e38360a6ada8630d36b1
image: ghostunnel/ghostunnel:v1.11.5@sha256:65c4e34ec0ee0f30ea1f72b7411ec70a91c32fc50f067a8466ad2adb1e7420f2
args:
- client
- --listen=127.0.0.1:11883
@@ -275,7 +275,7 @@ spec:
# any LAN client can edit, matching the user's trust-the-network
# model. Runs as root so its writes match HA's root-owned files.
- name: code-server
image: codercom/code-server:4.141.0@sha256:72ab24b5680ee597dd647b30d82625752f40109b5f61bef90ffbf1ea1f02dbb8
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
args:
- "--auth=none"
- "--bind-addr=0.0.0.0:8080"
@@ -337,7 +337,7 @@ spec:
# dedicated long-lived token.
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
- name: config-sync
image: alpine/git:2.54.0@sha256:03228d7ded483b0b3e55e1de7fa660d70d3e1bb432fa7021d29e192640de11aa
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
@@ -36,7 +36,7 @@ spec:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.9.1@sha256:776a845d508909bf426d2ee7bdd81796361ebe6a7f091807ce1d6d0b48985413
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -12,26 +12,20 @@ resources:
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- matrix-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Former `health` probe credential. The broker no longer reads it (the
# probes use the cert account mosquitto-probe, REN-953); removed with its
# rotator in the REN-802 cleanup step.
- mosquitto-health-credentials-externalsecret.yaml
- mosquitto-admin-password.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
- mosquitto-iot-service.yaml
- mosquitto-deployment.yaml
- mosquitto-evcc-credentials-externalsecret.yaml
# shelly-manager and the mDNS relay sit on the IoT VLAN: ../home-assistant-iot.
- esphome-pvc.yaml
- esphome-deployment.yaml
- esphome-service.yaml
- esphome-ingress.yaml
# Zigbee2MQTT — ZHA replacement bridge. Ships scaled to 0; the cutover
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. The MQTT
# `zigbee2mqtt` broker user is provisioned by mosquitto-bootstrap.yaml
# (above) and wired into mosquitto-deployment.yaml (passwd + ACL).
# runbook (zigbee2mqtt-migration-runbook.md) scales it up. It logs in to
# the broker as register account `zigbee2mqtt-bridge` (mqtt-accounts.yaml).
- zigbee2mqtt-coordinator-backup-externalsecret.yaml
- zigbee2mqtt-config.yaml
- zigbee2mqtt-pvc.yaml
@@ -0,0 +1,32 @@
# Password of the broker's `admin` workstation login (mosquitto-deployment.yaml).
# Generated once in-cluster, nothing in OpenBao. Read it with
# kubectl -n home-assistant get secret mosquitto-admin-password -o jsonpath='{.data.password}' | base64 -d
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: mosquitto-admin-password
namespace: home-assistant
spec:
length: 32
digits: 8
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- password
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-admin-password
namespace: home-assistant
spec:
refreshPolicy: CreatedOnce
target:
name: mosquitto-admin-password
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: mosquitto-admin-password
@@ -1,394 +0,0 @@
---
# Generates mosquitto-credentials on first deploy. The password is created
# in-cluster, lives only in the Secret object, and is never written to git
# or seen by a human. The Mosquitto broker hashes it via mosquitto_passwd
# -U at startup; HA reads the same value from this Secret (configured
# manually in the HA MQTT integration UI).
#
# Idempotent: if the Secret already exists, the Job exits 0 without
# regenerating, so subsequent Flux reconciles don't rotate the password.
# To rotate, delete both the Secret and this Job; Flux will re-create
# the Job and a fresh password.
apiVersion: v1
kind: ServiceAccount
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mosquitto-bootstrap
namespace: home-assistant
subjects:
- kind: ServiceAccount
name: mosquitto-bootstrap
namespace: home-assistant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mosquitto-bootstrap
---
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {"homeassistant-password": $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Third broker user: `admin`. Full readwrite (same as HA) but kept on
# its own credential so a workstation client can hold a password the
# in-cluster HA config never sees, and rotating one doesn't affect the
# other. Retrieve with:
# kubectl -n home-assistant get secret mosquitto-admin-credentials \
# -o jsonpath='{.data.password}' | base64 -d
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-admin-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-admin-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
# its lifecycle is independent of the others. The sml2mqtt pod reads this
# password via its init container and renders it into config.yml.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-sml2mqtt-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-sml2mqtt-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Fifth broker user: `open3e` — the Viessmann E3 CAN bridge (Vitocal 250-A
# + Vitocharge VX3 via open3e). Scoped to `open3e/#` (ACL in
# mosquitto-deployment.yaml). Own credential so its lifecycle is
# independent of the others. Retrieve with:
# kubectl -n home-assistant get secret mosquitto-open3e-credentials \
# -o jsonpath='{.data.password}' | base64 -d
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-open3e-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-open3e-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
# publish HA MQTT-discovery configs) and
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
# republishes cached device states, NOT discovery configs — ACL and the full
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
# independent of the others. The Z2M init container reads this password and
# renders it into /app/data/secret.yaml.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-zigbee2mqtt-credentials-bootstrap
namespace: home-assistant
annotations:
# Job pod templates are immutable; force-recreate so image bumps
# apply on Flux re-reconcile. Safe: the Job is idempotent (see above).
kustomize.toolkit.fluxcd.io/force: enabled
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-zigbee2mqtt-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
@@ -77,18 +77,15 @@ data:
allow_anonymous false
# Two account sources side by side (REN-802 step 2, tested on 2.0.22 in
# REN-900, document `coexistence`):
# Dynamic Security: accounts from the register mqtt-accounts.yaml,
# managed live by mqtt-account-controller over $CONTROL.
# password_file/acl_file: the legacy users, unchanged until each client
# moves to the register (step 3).
# Accounts (tested on 2.0.22 in REN-900, document `coexistence`):
# Dynamic Security: every client account, from the register
# mqtt-accounts.yaml, managed live by mqtt-account-controller over
# $CONTROL. Applies to all listeners (per_listener_settings false).
# password_file/acl_file: the owner's workstation login `admin` only
# (mosquitto-deployment.yaml).
# Dynamic Security is asked first. For a username it does not know, and
# for a request none of its roles match, it defers to the files. A
# register entry with the same name as a legacy user therefore replaces
# that user's password, while its acl_file block still applies to
# whatever the entry's roles do not cover. Delete the legacy user when
# its client moves.
# register entry named `admin` therefore replaces the file login.
#
# The acl_file check of 2.0 allows every SUBSCRIBE and checks only on
# delivery, so the Dynamic Security default must deny publishClientReceive.
@@ -64,67 +64,15 @@ spec:
- -c
- |
set -eu
# `admin` is the owner's workstation login over the LoadBalancer
# (1883/8883), full readwrite. The only password_file user: the
# controller cannot deliver a password account to a Secret, and
# the cert listener :8884 is in-cluster only.
rm -f /mosquitto/auth/passwd
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
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 evcc "$EVCC_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd zigbee2mqtt "$ZIGBEE2MQTT_PASSWORD"
# ACL: HA keeps full access (subscribes to everything). Shellies
# are not legacy users any more: each has its own Dynamic Security
# account from mqtt-accounts.yaml (REN-802, REN-1017).
# `admin` is a workstation/debugging credential — full
# readwrite, kept on its own credential so its password can
# be rotated independently of the HA session.
# `sml2mqtt` is the power-meter bridge — WRITE-only under its own
# `sml2mqtt/#` subtree (it only publishes; HA reads it via the
# full-access homeassistant user).
# `open3e` is the Viessmann E3 CAN bridge (Vitocal/VX3) —
# scoped to its own `open3e/#` subtree: publishes datapoints
# and later subscribes to `open3e/cmnd` in listener mode (HA
# reads via the full-access homeassistant user).
# `evcc` is the (cross-namespace) EV charge controller — reads
# `open3e/#` (VX3 grid/PV/battery/SoC datapoints), publishes its
# own state under `evcc/#`, and reads the grid price from
# `energy/#` (HA republishes input_number.strompreis to
# energy/tariff/grid; kept OUTSIDE evcc/# so evcc's own retained-
# topic management can't wipe it).
mosquitto_passwd -c -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
cat > /mosquitto/auth/acl <<'EOF'
user homeassistant
topic readwrite #
user admin
topic readwrite #
user sml2mqtt
topic write sml2mqtt/#
user open3e
topic readwrite open3e/#
user evcc
topic read open3e/#
topic readwrite evcc/#
topic read energy/#
# `zigbee2mqtt` is the Zigbee2MQTT bridge (the ZHA replacement).
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
# to publish HA MQTT-discovery configs (write-only there, to deny
# discovery-tree recon from a compromised bridge); and READs `homeassistant/status`, HA's birth/will topic.
#
# That read grant does NOT buy a discovery re-publish: on
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
# states only (lib/extension/homeassistant.ts onMQTTMessage) —
# discover() runs solely from start(). The grant is still correct,
# because the cached-state republish is what makes HA show current
# values right after an HA restart. Restoring discovery after a
# BROKER restart is handled by the discovery-guard sidecar in
# zigbee2mqtt-deployment.yaml.
user zigbee2mqtt
topic readwrite zigbee2mqtt/#
topic write homeassistant/#
topic read homeassistant/status
EOF
# Dynamic Security start file (REN-802): the controller's seed
# if it exists, else the static base file. The seed carries
@@ -143,38 +91,11 @@ spec:
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl /mosquitto/auth/dynamic-security.json
exec mosquitto -c /mosquitto/config/mosquitto.conf
env:
- name: HA_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-credentials
key: homeassistant-password
# mosquitto-admin-password.yaml
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-admin-credentials
key: password
- name: SML2MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-sml2mqtt-credentials
key: password
- name: OPEN3E_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-open3e-credentials
key: password
- name: EVCC_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-evcc-credentials
key: password
# Zigbee2MQTT bridge credential (mosquitto-zigbee2mqtt-credentials-
# bootstrap). Hashed into the passwd file here; Z2M reads the same
# Secret via its init container to authenticate to the broker.
- name: ZIGBEE2MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-zigbee2mqtt-credentials
name: mosquitto-admin-password
key: password
ports:
- name: mqtt
@@ -231,12 +152,12 @@ spec:
# Service and the probe dials 127.0.0.1; the chain is still checked
# against mqtt-ca.
#
# mosquitto is a single-replica SPOF (HA, Shelly, sml2mqtt, open3e,
# evcc). Liveness is deliberately slow to fire — failureThreshold 6
# x 30s = 3 min of sustained failure before a restart — so a
# transient hiccup never bounces the broker every client depends
# on. The startupProbe (tcpSocket) suspends both while the
# container builds the passwd/acl files and mosquitto binds :1883.
# mosquitto is a single-replica SPOF (HA, Shelly, Zigbee2MQTT,
# edge-1, evcc). Liveness is deliberately slow to fire —
# failureThreshold 6 x 30s = 3 min of sustained failure before a
# restart — so a transient hiccup never bounces the broker every
# client depends on. The startupProbe (tcpSocket) suspends both while
# the container prepares /mosquitto/auth and mosquitto binds :1883.
startupProbe:
tcpSocket:
port: mqtt
@@ -1,15 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-evcc-credentials
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-evcc-credentials # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-evcc-credentials
@@ -1,36 +0,0 @@
# Projects the mosquitto `health` broker credential from OpenBao into the Secret
# the broker already consumes. FIRST of the eight broker users to move off the
# bootstrap Jobs and onto Class-A rotation (see
# PROPOSAL-mosquitto-credential-rotation.md (PR #453 — not yet merged; this
# file lands with it)); the rotator lives at
# kubernetes/infrastructure/openbao/rotation/mosquitto-health.yaml.
#
# Secret name and key are UNCHANGED (`password`), so mosquitto-deployment.yaml's
# secretKeyRef and the exec probes that read $HEALTH_PASSWORD need no edit.
#
# refreshInterval is 10m rather than the 1h used by mosquitto-evcc-credentials:
# a rotation only lands once ESO re-reads, so 1h would mean up to an hour between
# the rotator running and the broker actually using the new password, with the
# old one still valid in the meantime. 10m keeps that window short without
# polling OpenBao pointlessly hard.
#
# ⚠️ OWNERSHIP: creationPolicy is implicitly Owner, so ESO sets an ownerReference
# on the Secret. Deleting this ExternalSecret therefore DELETES the live Secret
# and with it the broker's probe credential — the bootstrap Job it replaces had
# no such coupling. To detach without destroying, set creationPolicy: Orphan
# before removing it.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-health-credentials
namespace: home-assistant
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-health-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-health-credentials
@@ -5,9 +5,7 @@
# mqtt-accounts.yaml, every INTERVAL (2m).
#
# Manages the production broker `mosquitto` over its mTLS listener :8884
# (REN-802 step 2, REN-900). The legacy users in that broker's password_file
# are not Dynamic Security clients, so the controller never sees or touches
# them.
# (REN-802 step 2, REN-900).
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -87,6 +85,9 @@ subjects:
# delivered to this namespace. Add the name with the register entry.
# mqtt-ha-cert-sync: sha256 of the client certificate last accepted by
# Home Assistant's MQTT config entry (HA cert sync, REN-953/REN-1284).
# mqtt-<name>-password: the login of a password account delivered to this
# namespace, written, rewritten and deleted on revocation. One entry per
# account.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
@@ -99,6 +100,11 @@ rules:
resources: ["secrets"]
resourceNames: ["mqtt-account-hashes", "mqtt-dynsec-seed", "mqtt-ha-cert-sync"]
verbs: ["get", "create", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
- mqtt-admin-password
verbs: ["get", "create", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
@@ -219,7 +225,7 @@ spec:
- name: controller
# Woodpecker builds main-<sha> on every merge to main; no sortable
# tag, so bumps are manual (same as ical-proxy).
image: gitea.renner.uno/konairius/mqtt-account-controller:main-cb96b1a@sha256:9d9b9e83a9c0f9f567e57edacab11cf4c58e8c0e49e38e8d7b51421cd1242911
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -7,51 +7,50 @@
# configMapGenerator in kustomization.yaml). An empty list is refused, so it
# never revokes everyone.
#
# The controller manages the production broker `mosquitto` (REN-900). Its
# legacy users (password_file in mosquitto-deployment.yaml) are not listed
# here; each moves here with its client (REN-802 step 3). `test-cert` is the
# certificate test account from REN-888.
# The controller manages the production broker `mosquitto` (REN-900).
# `test-cert` is the certificate test account from REN-888.
#
# A cert account needs its Secret `mqtt-<name>-tls` added to the
# A cert account needs its Secret `mqtt-<name>-tls`, a password account
# delivered to a namespace its Secret `mqtt-<name>-password`, added to the
# `mqtt-account-controller-secrets-<namespace>` Role in
# mqtt-account-controller.yaml, or revoking it fails with 403.
# mqtt-account-controller.yaml, or writing or revoking it fails with 403.
# The owner's workstation login over the LoadBalancer (1883/8883). Password in
# the Secret mqtt-admin-password (home-assistant); deleting the Secret makes a
# new one.
- name: admin
auth: password
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
- name: test-cert
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite test/cert/#"]
# The broker's own liveness/readiness probe (mosquitto-deployment.yaml).
# Not named `health`: a Dynamic Security client of that name would shadow the
# legacy password user while the old broker pod still probes with it.
- name: mosquitto-probe
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite health/#"]
# Zigbee2MQTT and its two sidecars (discovery-guard, interview-watch), one
# certificate for the pod. Same ACL as the legacy user `zigbee2mqtt`; not
# named like it, so this entry cannot shadow the password login of a running
# Z2M (REN-953).
# certificate for the pod (REN-953).
- name: zigbee2mqtt-bridge
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite zigbee2mqtt/#", "write homeassistant/#", "read homeassistant/status"]
# evcc (evcc namespace). Same ACL as the legacy user `evcc`, different name
# so it cannot shadow the running password login (REN-953). evcc takes the
# certificate PEMs by env (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
# evcc (evcc namespace, REN-953). evcc takes the certificate PEMs by env
# (EVCC_MQTT_*), see apps/evcc/deployment.yaml.
- name: evcc-client
auth: cert
deliver: {namespace: evcc}
acl: ["read open3e/#", "readwrite evcc/#", "read energy/#"]
# Home Assistant, via the ghostunnel sidecar `mqtt-tls` in its pod (HA keeps
# client certificates only as uploaded PEMs in its config entry, so a renewal
# could not reach it without a reconfigure). Same ACL as the legacy user
# `homeassistant`; not named like it, so it cannot shadow that login (REN-953).
# could not reach it without a reconfigure, REN-953).
- name: home-assistant
auth: cert
deliver: {namespace: home-assistant}
acl: ["readwrite #"]
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan
# and sml2mqtt, replacing the legacy password users `open3e` and `sml2mqtt`
# (different name, so it cannot shadow their logins). The controller pushes
# edge-1's local mosquitto bridge (REN-1307): one bridge for open3e/e3oncan.
# The controller pushes
# the password to edge-mqtt-cred on edge-1 (ansible role mqtt-bridge) over
# HTTPS with its own client certificate, and pins edge-1's self-generated
# server certificate on first contact (key edge-1.pin in mqtt-account-hashes;
@@ -50,13 +50,13 @@ release it before Z2M connects (step 3).
Confirm the standing pieces are in place (nothing is running yet):
```
kubectl -n home-assistant get secret mosquitto-zigbee2mqtt-credentials # exists (bootstrap Job ran)
kubectl -n home-assistant get secret mqtt-zigbee2mqtt-bridge-tls # exists (register account zigbee2mqtt-bridge)
kubectl -n home-assistant get externalsecret zigbee2mqtt-coordinator-backup # SecretSyncedError / NotReady — EXPECTED
kubectl -n home-assistant get deploy zigbee2mqtt # 0/0 — EXPECTED
```
The `mosquitto` pod will have rolled to pick up the new `zigbee2mqtt` passwd/ACL
entry. Verify the broker still serves HA (it will — the change is additive).
Z2M logs in to the broker by certificate on `:8884` as the register account
`zigbee2mqtt-bridge` (`mqtt-accounts.yaml`).
## Step 1 — export the Open Coordinator Backup (non-disruptive)
+1 -1
View File
@@ -66,7 +66,7 @@ spec:
# The library PVC is populated by the seed phase before this
# Deployment reconciles, so no rsync init is needed here.
- name: fetch-le-staging-roots
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
command:
- sh
- -c
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -80,7 +80,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+2 -2
View File
@@ -88,7 +88,7 @@ spec:
# state). Two-stage so we don't try to create an auth before
# the chart's `influx setup` has finished.
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -111,7 +111,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+1 -1
View File
@@ -55,7 +55,7 @@ spec:
repository: influxdb
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
tag: "2.9.1@sha256:cdd3dbdea45c2f43513abd06ee9644b6e05d2354a2b93ca829c9f07d716eaaac"
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.30@sha256:3e7dbd4424de9e11c812bc740acf2725b2abe66ea0d0802023b56fd85d3fa09a
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -137,7 +137,7 @@ spec:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0@sha256:e089f1048a1d4a9a492ed17b9fe759100f1bd619407b001f5927928d88b780c4
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
+1 -1
View File
@@ -124,7 +124,7 @@ spec:
type: RuntimeDefault
containers:
- name: livekit
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
image: livekit/livekit-server:v1.13.11@sha256:25cd902de7cacbd5f1f779f6a6ac1eae20ba8f56b0935934ce6b0ab80d1e3d20
args:
- --config
- /config/livekit.yaml
+2 -2
View File
@@ -179,7 +179,7 @@ spec:
# first. The marker on the data PVC makes later starts skip it.
# Debug image: the plain one has no shell for the marker check.
- name: migrate-to-mas
image: ghcr.io/element-hq/matrix-authentication-service:1.26.0-debug@sha256:46f7480f5ea4243ccb6fd2c447ea859e0230c8b3c0e676ee285d4ae6ca344ee3
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
command:
- /busybox/sh
- -ec
@@ -234,7 +234,7 @@ spec:
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.162.0@sha256:6b84a7bbac36f080b2d2e51e0289cf1b08b349598ea44a558df38d558f2c2311
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
# Bypass /start.py: it expects to run as root and template a config
# from env. Default mode is generate-missing-and-run, which creates
# the signing key on first start.
@@ -5,7 +5,9 @@
#
# 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.
# a deleted record green for an hour. The addresses are the UDM on the segment
# pod traffic leaves on, the same upstream k8s-gateway uses (k8s-gateway-site
# in talos/patches/site-homelab.yaml).
#
# `release` is load-bearing — probeSelector matches on it.
apiVersion: monitoring.coreos.com/v1
@@ -25,8 +27,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
# instance is the resolver and identical across these Probes, so the
# queried name has to come from a label.
labels:
@@ -49,8 +51,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: mqtt.renner.uno
---
@@ -71,8 +73,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: ldap.renner.uno
---
@@ -93,8 +95,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: syslog.renner.uno
---
@@ -115,8 +117,8 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: ftp.paperless.renner.uno
---
@@ -137,7 +139,7 @@ spec:
targets:
staticConfig:
static:
- "[fd00::1]:53"
- "10.2.40.1:53"
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: syncthing.paperless.renner.uno
@@ -82,9 +82,9 @@ spec:
#
# The pod regex is the Deployment's name shape
# (mosquitto-<replicaset hash>-<suffix>) and not `mosquitto-.*` on
# purpose: mosquitto-bootstrap.yaml runs six Jobs named
# mosquitto-*-credentials-bootstrap, and a bootstrap pod that is
# briefly Ready must not hide a broker that is down.
# purpose: other pods in the namespace start with `mosquitto-`
# (mosquitto-dynsec-test), and a Ready one must not hide a broker
# that is down.
expr: |
absent(kube_pod_status_ready{namespace="home-assistant", pod=~"mosquitto-[a-z0-9]{6,10}-[a-z0-9]{5}", condition="true"} == 1)
for: 5m
@@ -137,13 +137,13 @@ tests:
exp_alerts: []
# --- MosquittoDown ------------------------------------------------------
# The broker goes not-Ready at t=10m while a credentials-bootstrap Job pod is
# Ready. The Job pod must not mask the broker.
# The broker goes not-Ready at t=10m while the mosquitto-dynsec-test pod is
# Ready. That pod must not mask the broker.
- interval: 1m
input_series:
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-7f8b9c6d5-x2k9q",condition="true"}'
values: '1x9 0x20'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-credentials-bootstrap-q7w2e",condition="true"}'
- series: 'kube_pod_status_ready{namespace="home-assistant",pod="mosquitto-dynsec-test-6d9f8b7c5-r4t8w",condition="true"}'
values: '1x30'
alert_rule_test:
- eval_time: 14m
@@ -32,6 +32,9 @@ spec:
deploymentMode: SingleBinary
loki:
auth_enabled: false
# No usage reports to stats.grafana.org (egress is not allowed anyway).
analytics:
reporting_enabled: false
commonConfig:
replication_factor: 1
storage:
@@ -17,7 +17,7 @@ spec:
chart:
spec:
chart: kube-prometheus-stack
version: "92.3.0"
version: "92.13.1"
sourceRef:
kind: HelmRepository
name: prometheus-community
@@ -21,10 +21,13 @@ data:
the pod's default route — which players on the IoT VLAN cannot route to.
The v30iot address lives on the net1 macvlan child (see
network-attachment.yaml).
- bind_ip must be 0.0.0.0. The Sendspin server (aiosendspin, port 8927) has
NO all-interfaces fallback: if bind_ip is a specific IP that is not local
it fails to bind and the server never starts, and its proxy then targets
that dead IP instead of 127.0.0.1. Binding 0.0.0.0 avoids both.
- bind_ip must be the same live v30iot address. aiosendspin starts its
zeroconf on [bind_ip], or on InterfaceChoice.Default for 0.0.0.0; Default
sends mDNS queries via the main-table default route (eth0, net1 routes
live in the sbr table), so MA never queries the IoT VLAN for
_sendspin._tcp and Voice PEs are only rediscovered when they announce
on their own. The Sendspin server has no all-interfaces bind fallback, so
bind_ip must be local at start; it is, because it is read from net1 here.
The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored
IP is a stale assumption the moment MA reschedules or the lease changes -
@@ -91,15 +94,15 @@ data:
.setdefault("values", {})
)
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
if (old_pub, old_bind) == (ip, "0.0.0.0"):
if (old_pub, old_bind) == (ip, ip):
print("[reconcile] publish_ip/bind_ip already correct; no change")
return 0
values["publish_ip"] = ip
values["bind_ip"] = "0.0.0.0"
values["bind_ip"] = ip
print(
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
f"bind_ip {old_bind!r} -> '0.0.0.0'"
f"bind_ip {old_bind!r} -> {ip!r}"
)
tmp = SETTINGS + ".reconcile.tmp"
@@ -44,7 +44,9 @@ spec:
# which IoT players can't reach; and
# - net1 is DHCP and MA is not node-pinned, so any hard-coded IP goes
# stale on the next reschedule or lease change (the Sendspin server
# then fails to bind and dies, with no fallback).
# then fails to bind and dies, with no fallback); and
# - a 0.0.0.0 bind_ip makes Sendspin's mDNS browse query via eth0, so
# Voice PEs on the IoT VLAN are not rediscovered after a restart.
# So we recompute the v30iot IP from the live interface every boot and
# write it into /data/settings.json before MA reads it. Multus attaches
# net1 (with its DHCP lease) before any container runs, so this sees the
@@ -52,7 +54,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.7 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -60,9 +62,33 @@ spec:
- name: net-reconcile
mountPath: /scripts
readOnly: true
# Pocket Casts Up Next as a library playlist, pending upstream. Copies
# the image's provider and swaps in the patched __init__.py only while
# the stock file is the one it was written against (2.10.7 = 2.10.8);
# any other version keeps the stock provider.
- name: pocketcasts-overlay
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
command:
- /bin/sh
- -euc
- |
src=$(echo /app/venv/lib/python3*/site-packages/music_assistant/providers/pocketcasts)
cp -a "$src/." /overlay/
if echo "c88377d887148faf912ff217e021df3cd42bb7071dece0998b07c7bb12eb323e $src/__init__.py" | sha256sum -c --status; then
cp /patch/__init__.py /overlay/__init__.py
echo "pocketcasts: Up Next playlist patch applied"
else
echo "pocketcasts: stock provider changed upstream, patch skipped"
fi
volumeMounts:
- name: pocketcasts-overlay
mountPath: /overlay
- name: pocketcasts-patch
mountPath: /patch
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
@@ -81,6 +107,8 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: pocketcasts-overlay
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
livenessProbe:
httpGet:
path: /
@@ -125,6 +153,11 @@ spec:
configMap:
name: music-assistant-net-reconcile
defaultMode: 0555
- name: pocketcasts-overlay
emptyDir: {}
- name: pocketcasts-patch
configMap:
name: music-assistant-pocketcasts-up-next
---
apiVersion: v1
kind: Service
@@ -7,3 +7,10 @@ resources:
- deployment.yaml
- ingress.yaml
- kumamonitors.yaml
configMapGenerator:
# Pocket Casts provider with the Up Next playlist, applied by the
# pocketcasts-overlay initContainer. Hashed name: an edit rolls the pod.
- name: music-assistant-pocketcasts-up-next
namespace: music-assistant
files:
- __init__.py=pocketcasts-up-next/__init__.py
@@ -13,9 +13,9 @@
# initContainer (configmap-net-reconcile.yaml) discovers net1's leased
# address the same way it used to discover the host's ens19 address —
# scanning the pod's own interfaces for a 10.2.30.0/24 address — and
# writes it as MA's publish_ip. bind_ip stays 0.0.0.0, which now binds
# eth0 (pod) + net1; players reach the stream/slimproto/sendspin servers
# on net1. Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
# writes it as MA's publish_ip and bind_ip; players reach the
# stream/sendspin servers on net1. Requires the cni-dhcp-daemon DaemonSet
# in infrastructure/multus.
#
# Mirrors matter-server-iot; the only difference is MA does not speak
# Thread, so the matter NAD's accept_ra_rt_info_max_plen tuning (for the
@@ -0,0 +1,817 @@
"""Pocket Casts music provider for Music Assistant."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from music_assistant_models.enums import (
ContentType,
ImageType,
MediaType,
ProviderFeature,
StreamType,
)
from music_assistant_models.errors import (
LoginFailed,
MediaNotFoundError,
ProviderUnavailableError,
ResourceTemporarilyUnavailable,
RetriesExhausted,
)
from music_assistant_models.media_items import (
AudioFormat,
BrowseFolder,
ItemMapping,
MediaItemImage,
MediaItemMetadata,
MediaItemType,
Playlist,
Podcast,
PodcastEpisode,
ProviderMapping,
SearchResults,
UniqueList,
)
from music_assistant_models.streamdetails import StreamDetails
from music_assistant import MusicAssistant
from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
from music_assistant.controllers.cache import use_cache
from music_assistant.helpers.podcast_parsers import rank_episodes_by_date
from music_assistant.models.music_provider import MusicProvider
from .api_client import PocketCastsClient
if TYPE_CHECKING:
from datetime import datetime
from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
from music_assistant_models.provider import ProviderManifest
from music_assistant.models import ProviderInstanceType
LOGGER = logging.getLogger(__name__)
FULLY_PLAYED_THRESHOLD = 0.9
SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history")
UP_NEXT_PLAYLIST_ID = "up_next"
SUPPORTED_FEATURES = {
ProviderFeature.LIBRARY_PODCASTS,
ProviderFeature.LIBRARY_PLAYLISTS,
ProviderFeature.BROWSE,
ProviderFeature.SEARCH,
ProviderFeature.LIBRARY_PODCASTS_EDIT,
}
BROWSE_FOLDER_ICONS: dict[str, str] = {
"up_next": (
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
"bD0iIzhGOTdBNCIgZD0iTTMgMi45NzE1OUMzIDIuNTY0OSAzLjQ1OTY4IDIuMzI4MzQgMy43OTA2Mi"
"AyLjU2NDcyTDkuNDMwMzkgNi41OTMxM0M5LjcwOTU2IDYuNzkyNTQgOS43MDk1NiA3LjIwNzQ1IDku"
"NDMwMzkgNy40MDY4NkwzLjc5MDYyIDExLjQzNTNDMy40NTk2OSAxMS42NzE2IDMgMTEuNDM1MSAzID"
"ExLjAyODRWMi45NzE1OVoiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIG9wYWNpdHk9IjAuNS"
"IgZD0iTTEyIDdDMTIgNi40NDc3MiAxMi40NDc3IDYgMTMgNkgyMUMyMS41NTIzIDYgMjIgNi40NDc3"
"MiAyMiA3QzIyIDcuNTUyMjggMjEuNTUyMyA4IDIxIDhIMTNDMTIuNDQ3NyA4IDEyIDcuNTUyMjggMT"
"IgN1pNOSAxMkM5IDExLjQ0NzcgOS40NDc3MiAxMSAxMCAxMUgyMUMyMS41NTIzIDExIDIyIDExLjQ0Nz"
"cgMjIgMTJDMjIgMTIuNTUyMyAyMS41NTIzIDEzIDIxIDEzSDEwQzkuNDQ3NzIgMTMgOSAxMi41NTIz"
"IDkgMTJaTTEwIDE2QzkuNDQ3NzIgMTYgOSAxNi40NDc3IDkgMTdDOSAxNy41NTIzIDkuNDQ3NzIgMT"
"ggMTAgMThIMjFDMjEuNTUyMyAxOCAyMiAxNy41NTIzIDIyIDE3QzIyIDE2LjQ0NzcgMjEuNTUyMyAx"
"NiAyMSAxNkgxMFoiLz4KPC9zdmc+Cg=="
),
"new_releases": (
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPGcgZmlsbD0i"
"IzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMSkiPgogICAgICAgIDxwYXRoIGQ9Ik0xNC"
"43OTM1IDQuNTU3MzVMMTUuNzc0MSAwLjc4OTIwOUMxNS45Njg4IDAuMDQxMDUwOSAxNy4wMzExIDAu"
"MDQxMDcxOSAxNy4yMjU4IDAuNzg5MjM3TDE4LjIwNjIgNC41NTczMkMxOC4yNzcgNC44MjkxNSAxOC"
"40OTM3IDUuMDM4NjggMTguNzY3NyA1LjEwMDIzTDIxLjc0MTkgNS43NjgyM0MyMi41MjI4IDUuOTQz"
"NjIgMjIuNTIyOCA3LjA1NjM5IDIxLjc0MTkgNy4yMzE3N0wxOC43Njc3IDcuODk5NzdDMTguNDkzNy"
"A3Ljk2MTMyIDE4LjI3NyA4LjE3MDg1IDE4LjIwNjIgOC40NDI2OEwxNy4yMjU4IDEyLjIxMDhDMTcu"
"MDMxMSAxMi45NTg5IDE1Ljk2ODggMTIuOTU4OSAxNS43NzQxIDEyLjIxMDhMMTQuNzkzNSA4LjQ0Mj"
"Y1QzE0LjcyMjcgOC4xNzA4MyAxNC41MDYxIDcuOTYxMzIgMTQuMjMyIDcuODk5NzdMMTEuMjU4IDcu"
"MjMxNzdDMTAuNDc3MSA3LjA1NjM5IDEwLjQ3NzEgNS45NDM2MiAxMS4yNTggNS43NjgyNEwxNC4yMz"
"IgNS4xMDAyM0MxNC41MDYxIDUuMDM4NjggMTQuNzIyNyA0LjgyOTE3IDE0Ljc5MzUgNC41NTczNVoi"
"Lz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjgiIGQ9Ik01LjI5OTQgOS4yNjgzTDYuMDMwNjYgNy"
"4yNzc2M0M2LjE5MTEyIDYuODQwODUgNi44MDg4OCA2Ljg0MDg1IDYuOTY5MzQgNy4yNzc2M0w3Ljcw"
"MDYgOS4yNjgzQzcuNzU0MjUgOS40MTQzNSA3Ljg3Mjg0IDkuNTI3MSA4LjAyMTQxIDkuNTczMzJMOS"
"40NjU0MSAxMC4wMjI2QzkuOTM0MDMgMTAuMTY4MyA5LjkzNDAzIDEwLjgzMTYgOS40NjU0MSAxMC45"
"Nzc0TDguMDIxNCAxMS40MjY3QzcuODcyODMgMTEuNDcyOSA3Ljc1NDI1IDExLjU4NTYgNy43MDA2ID"
"ExLjczMTdMNi45NjkzMyAxMy43MjI0QzYuODA4ODggMTQuMTU5MiA2LjE5MTEyIDE0LjE1OTIgNi4w"
"MzA2NiAxMy43MjI0TDUuMjk5NCAxMS43MzE3QzUuMjQ1NzUgMTEuNTg1NiA1LjEyNzE3IDExLjQ3Mj"
"kgNC45Nzg2IDExLjQyNjdMMy41MzQ1OSAxMC45Nzc0QzMuMDY1OTcgMTAuODMxNiAzLjA2NTk3IDEw"
"LjE2ODMgMy41MzQ1OSAxMC4wMjI2TDQuOTc4NTkgOS41NzMzMkM1LjEyNzE2IDkuNTI3MSA1LjI0NT"
"c1IDkuNDE0MzUgNS4yOTk0IDkuMjY4M1oiLz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjYiIGQ9"
"Ik0xMC42ODgyIDE2LjAxNEwxMS41MjQ3IDEzLjQ1NDNDMTEuNjc0OSAxMi45OTQ3IDEyLjMyNTEgMT"
"IuOTk0NyAxMi40NzUzIDEzLjQ1NDNMMTMuMzExOCAxNi4wMTRDMTMuMzYwNCAxNi4xNjI3IDEzLjQ3"
"NTcgMTYuMjggMTMuNjIzNSAxNi4zMzEyTDE1LjYzNSAxNy4wMjc1QzE2LjA4MzYgMTcuMTgyOCAxNi"
"4wODM2IDE3LjgxNzIgMTUuNjM1IDE3Ljk3MjVMMTMuNjIzNSAxOC42Njg4QzEzLjQ3NTcgMTguNzIg"
"MTMuMzYwNCAxOC44MzczIDEzLjMxMTggMTguOTg2TDEyLjQ3NTMgMjEuNTQ1N0MxMi4zMjUxIDIyLj"
"AwNTMgMTEuNjc0OSAyMi4wMDUzIDExLjUyNDcgMjEuNTQ1N0wxMC42ODgyIDE4Ljk4NkMxMC42Mzk2"
"IDE4LjgzNzMgMTAuNTI0MyAxOC43MiAxMC4zNzY1IDE4LjY2ODhMOC4zNjQ5OCAxNy45NzI1QzcuOT"
"E2MzggMTcuODE3MiA3LjkxNjM5IDE3LjE4MjggOC4zNjQ5OCAxNy4wMjc1TDEwLjM3NjUgMTYuMzMx"
"MkMxMC41MjQzIDE2LjI4IDEwLjYzOTYgMTYuMTYyNyAxMC42ODgyIDE2LjAxNFoiLz4KICAgIDwvZz"
"4KPC9zdmc+Cg=="
),
"in_progress": (
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBkPSJNNCAxMkM0IDE2LjQxODMgNy41ODE3MiAyMCAxMi"
"AyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3MiAxNi40MTgzIDQgMTIgNEM3"
"LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJaTTE4IDEyQzE4IDE1LjMxMzcgMTUuMzEzNyAxOCAxMiAxOE"
"M4LjY4NjI5IDE4IDYgMTUuMzEzNyA2IDEyQzYgOC42ODYyOSA4LjY4NjI5IDYgMTIgNkMxNS4zMTM3"
"IDYgMTggOC42ODYyOSAxOCAxMloiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGQ9Ik0xNi45Mj"
"UzIDE4LjMwNDFDMjAuNDA2OSAxNS41ODM5IDIxLjAyNDMgMTAuNTU2NCAxOC4zMDQxIDcuMDc0NzJD"
"MTYuODI2OSA1LjE4Mzk0IDE0LjYxNjcgNC4wODMyNyAxMi4yNjUgNC4wMDQyNEMxMS43MTMxIDMuOT"
"g1NjkgMTEuMjUwNiA0LjQxODExIDExLjIzMiA0Ljk3MDA4QzExLjIxMzUgNS41MjIwNiAxMS42NDU5"
"IDUuOTg0NTYgMTIuMTk3OSA2LjAwMzExQzEzLjk2MzkgNi4wNjI0NiAxNS42MTkzIDYuODg2ODYgMT"
"YuNzI4MSA4LjMwNjA1QzE4Ljc2ODIgMTAuOTE3MyAxOC4zMDUyIDE0LjY4OCAxNS42OTQgMTYuNzI4"
"MUMxNS4yNTg4IDE3LjA2ODEgMTUuMTgxNiAxNy42OTY1IDE1LjUyMTYgMTguMTMxOEMxNS44NjE2ID"
"E4LjU2NyAxNi40OTAxIDE4LjY0NDEgMTYuOTI1MyAxOC4zMDQxWiIvPgo8L3N2Zz4K"
),
"starred": (
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
"bD0iIzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSwgLTAuNSkiIGQ9Ik0xMS41ODMxIDE3Lj"
"Q3NzZMOC4wNzM4NCAxOS4yOTMzQzcuMDk0MTMgMTkuODAwMiA2LjQ0NjgxIDE5LjMxOTcgNi42MjQ2"
"NiAxOC4yNDA0TDcuMjY3MDcgMTQuMzQxOEw0LjQ1NTgxIDExLjU2NTRDMy42NzA5NyAxMC43OTAzID"
"MuOTI3OTEgMTAuMDI2MiA1LjAwOTM1IDkuODYxNzdMOC45MTU2NSA5LjI2ODAxTDEwLjY4NzUgNS43"
"MzYzOEMxMS4xODIxIDQuNzUwNDIgMTEuOTg4MiA0Ljc1ODY3IDEyLjQ3ODggNS43MzYzOEwxNC4yNT"
"A2IDkuMjY4MDFMMTguMTU2OSA5Ljg2MTc3QzE5LjI0NzQgMTAuMDI3NSAxOS40ODg3IDEwLjc5Njgg"
"MTguNzEwNCAxMS41NjU0TDE1Ljg5OTIgMTQuMzQxOEwxNi41NDE2IDE4LjI0MDRDMTYuNzIwOSAxOS"
"4zMjg4IDE2LjA2MzkgMTkuNzk2IDE1LjA5MjQgMTkuMjkzM0wxMS41ODMxIDE3LjQ3NzZaIi8+Cjwv"
"c3ZnPgo="
),
"history": (
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDEzQz"
"EwLjUgMTMuNTUyMyAxMC45NDc3IDE0IDExLjUgMTRIMTQuNUMxNS4wNTIzIDE0IDE1LjUgMTMuNTUy"
"MyAxNS41IDEzQzE1LjUgMTIuNDQ3NyAxNS4wNTIzIDEyIDE0LjUgMTJIMTIuNUwxMi41IDEwQzEyLj"
"UgOS40NDc3MiAxMi4wNTIzIDkgMTEuNSA5QzEwLjk0NzcgOSAxMC41IDkuNDQ3NzIgMTAuNSAxMFYx"
"M1oiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1y"
"dWxlPSJldmVub2RkIiBkPSJNMTIgMThDMTUuMzEzNyAxOCAxOCAxNS4zMTM3IDE4IDEyQzE4IDguNj"
"g2MjkgMTUuMzEzNyA2IDEyIDZDOC42ODYyOSA2IDYgOC42ODYyOSA2IDEyQzYgMTUuMzEzNyA4LjY4"
"NjI5IDE4IDEyIDE4Wk0xMiAyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3Mi"
"AxNi40MTgzIDQgMTIgNEM3LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJDNCAxNi40MTgzIDcuNTgxNzIg"
"MjAgMTIgMjBaIi8+Cjwvc3ZnPgo="
),
}
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
return PocketCastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
class PocketCastsProvider(MusicProvider):
"""Provider for Pocket Casts podcast service."""
_client: PocketCastsClient
# episode uuids already mirrored to Pocket Casts Up Next/history this session, keyed as a
# set since multi-room playback can have several episodes in progress on one instance
_announced_episodes: set[str]
async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
"""Return Config entries to configure this provider."""
return ()
async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
email = self.get_setup_value(CONF_USERNAME)
password = self.get_setup_value(CONF_PASSWORD)
if not email or not password:
raise LoginFailed("Email and password are required for Pocket Casts")
self._announced_episodes = set()
self._client = PocketCastsClient(self.mass.http_session, self.logger)
await self._client.login(str(email), str(password))
async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
"""Get all podcasts from the user's library."""
for podcast_data in await self._client.get_subscribed_podcasts():
yield self._convert_podcast(podcast_data)
async def library_add(self, item: MediaItemType) -> bool:
"""
Subscribe to a podcast.
:param item: The media item to add to the library.
"""
if not isinstance(item, Podcast):
return await super().library_add(item)
await self._client.subscribe_podcast(item.item_id)
return True
async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
"""
Unsubscribe from a podcast.
:param prov_item_id: The provider item ID to remove from the library.
:param media_type: The media type of the item.
"""
if media_type != MediaType.PODCAST:
return await super().library_remove(prov_item_id, media_type)
await self._client.unsubscribe_podcast(prov_item_id)
return True
@use_cache(3600 * 24)
async def get_podcast(self, prov_podcast_id: str) -> Podcast:
"""
Get full details for a podcast.
:param prov_podcast_id: The provider podcast id.
"""
podcast_data = await self._client.get_podcast(prov_podcast_id)
if not podcast_data:
raise MediaNotFoundError(
f"podcast://{prov_podcast_id} not found on provider {self.domain}"
)
return self._convert_podcast(podcast_data)
async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
"""
Get all episodes for a podcast, enriched with playback status.
:param prov_podcast_id: The provider podcast id.
"""
# fetch episode metadata, user status and show notes in parallel
(podcast_name, episodes), in_progress, history, show_notes = await asyncio.gather(
self._client.get_podcast_episodes(prov_podcast_id),
self._client.get_in_progress_episodes(),
self._client.get_history(),
self._get_show_notes(prov_podcast_id),
)
in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
history_map = {ep.get("uuid"): ep for ep in history}
# the full-podcast payload carries no episode number, so rank on the publication date
positions = rank_episodes_by_date([ep.get("published") or None for ep in episodes])
for position, episode_data in zip(positions, episodes, strict=True):
episode_item = self._convert_episode(
episode_data,
prov_podcast_id,
show_notes.get(episode_data.get("uuid", "")),
podcast_name,
position=position,
)
if episode_item:
self._enrich_episode_with_status(
episode_item, episode_data, in_progress_map, history_map
)
yield episode_item
async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
"""Get the user's Up Next queue as a playlist."""
yield self._get_up_next_playlist()
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
"""
Get full details for a playlist.
:param prov_playlist_id: The provider playlist id.
"""
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
raise MediaNotFoundError(
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
)
return self._get_up_next_playlist()
async def get_playlist_tracks(
self, prov_playlist_id: str, page: int = 0
) -> list[PodcastEpisode]:
"""
Get the episodes of a playlist, in Up Next order.
:param prov_playlist_id: The provider playlist id.
:param page: Zero-based page index; Up Next is returned whole on the first page.
"""
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
raise MediaNotFoundError(
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
)
if page > 0:
return []
episodes = await self._get_special_folder_episodes(UP_NEXT_PLAYLIST_ID)
# the Up Next payload carries no duration or playback status, and marking an episode
# played reports its duration as the position, so take both from the episode details
details = await asyncio.gather(
*(self.get_podcast_episode(episode.item_id) for episode in episodes),
return_exceptions=True,
)
items: list[PodcastEpisode] = []
for episode, detail in zip(episodes, details, strict=True):
if isinstance(detail, PodcastEpisode):
items.append(detail)
elif isinstance(
detail,
MediaNotFoundError
| LoginFailed
| ProviderUnavailableError
| ResourceTemporarilyUnavailable
| RetriesExhausted,
):
self.logger.debug("Could not retrieve details for %s: %s", episode.name, detail)
items.append(episode)
else:
raise detail
return items
async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
"""
Browse this provider's items.
:param path: The browse path to resolve.
"""
item_path = path.split("://", 1)[1] if "://" in path else path
if not item_path:
# root level - special folders followed by subscribed podcasts
items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
for podcast_data in await self._client.get_subscribed_podcasts():
items.append(self._convert_podcast(podcast_data))
return items
if item_path in SPECIAL_FOLDERS:
return list(await self._get_special_folder_episodes(item_path))
return [episode async for episode in self.get_podcast_episodes(item_path)]
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
"""
Get streamable URL and details for the given media item.
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
:param media_type: The media type of the item.
"""
_, episode_uuid = item_id.split(":", 1)
episode_data = await self._client.get_episode_details(episode_uuid)
url = episode_data.get("url", "")
if not url:
raise MediaNotFoundError(f"No URL found for episode {item_id}")
return StreamDetails(
item_id=item_id,
provider=self.instance_id,
audio_format=AudioFormat(
content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
),
media_type=MediaType.PODCAST_EPISODE,
stream_type=StreamType.HTTP,
path=url,
duration=episode_data.get("duration"),
can_seek=True,
allow_seek=True,
)
@use_cache(3600 * 24)
async def search(
self, search_query: str, media_types: list[MediaType], limit: int = 5
) -> SearchResults:
"""
Search for podcasts.
:param search_query: The search query.
:param media_types: The media types to include in the search.
:param limit: The maximum number of items to return per type.
"""
results = SearchResults()
if media_types and MediaType.PODCAST not in media_types:
return results
podcasts = await self._client.search_podcasts(search_query)
results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
return results
@use_cache(3600)
async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
"""
Get full details for a podcast episode.
:param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
"""
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
episode_data, show_notes, podcast_name = await asyncio.gather(
self._client.get_episode_details(episode_uuid),
self._get_show_notes(podcast_uuid),
self._get_podcast_name(podcast_uuid),
)
episode_item = self._convert_episode(
episode_data, podcast_uuid, show_notes.get(episode_uuid), podcast_name
)
if episode_item is None:
raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
# the API sends explicit nulls for these fields, so a dict default is not enough
played_up_to = episode_data.get("playedUpTo") or 0
duration = episode_data.get("duration") or 0
playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
if duration > 0:
episode_item.duration = duration
completed = playing_status == 3 or (
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
)
episode_item.fully_played = completed
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
return episode_item
async def get_resume_position(
self, item_id: str, media_type: MediaType
) -> tuple[bool, int, datetime | None]:
"""
Return the (fully_played, position_ms, timestamp) resume point for an episode.
PocketCasts does not expose a reliable last-played timestamp, so the timestamp
is always None.
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
:param media_type: The media type (should be PODCAST_EPISODE).
"""
_, episode_uuid = item_id.split(":", 1)
try:
in_progress = await self._client.get_in_progress_episodes()
except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
# resume is best-effort; a transient failure should not break playback
LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
return (False, 0, None)
for ep in in_progress:
if ep.get("uuid") == episode_uuid:
played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
duration = int(ep.get("duration") or 0)
fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
LOGGER.debug(
"Resume position for %s: %d ms (fully_played=%s)",
episode_uuid,
played_up_to * 1000,
fully_played,
)
return (fully_played, played_up_to * 1000, None)
LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
return (False, 0, None)
async def on_played(
self,
media_type: MediaType,
prov_item_id: str,
fully_played: bool,
position: int,
media_item: MediaItemType,
is_playing: bool = False,
) -> None:
"""
Sync playback progress for a podcast episode back to Pocket Casts.
Called by the Queue controller when a track is played, stopped/skipped, and
periodically while playing.
:param media_type: The media type of the played item.
:param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
:param fully_played: Whether the episode was played to the end.
:param position: Last known position in seconds.
:param media_item: The full media item details.
:param is_playing: Whether the episode is currently playing.
"""
if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
return
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
# MA reports fully_played=True when an episode is skipped/stopped, not only when it
# truly ends, so confirm completion against the real position before marking it played.
duration = media_item.duration or 0
completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
if completed:
self._announced_episodes.discard(episode_uuid)
await self._client.mark_episode_played(podcast_uuid, episode_uuid)
await self._client.remove_from_up_next(episode_uuid)
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
elif position == 0 and not is_playing:
# the user explicitly marked the episode as unplayed
self._announced_episodes.discard(episode_uuid)
await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
else:
# on_played fires every progress tick, so mirror the start to Up Next/history only
# once per session - re-announcing each tick would re-bump Up Next and spam the API.
# A resume within the same session is intentionally not re-announced.
if is_playing and episode_uuid not in self._announced_episodes:
self._announced_episodes.add(episode_uuid)
await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
"""
Convert raw Pocket Casts podcast data to a Podcast object.
:param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
"""
uuid = podcast_data["uuid"]
return Podcast(
item_id=uuid,
provider=self.instance_id,
name=podcast_data.get("title", ""),
publisher=podcast_data.get("author"),
provider_mappings={
ProviderMapping(
item_id=uuid,
provider_domain=self.domain,
provider_instance=self.instance_id,
)
},
metadata=MediaItemMetadata(
description=podcast_data.get("description"),
images=UniqueList(
[
MediaItemImage(
type=ImageType.THUMB,
path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
provider=self.instance_id,
remotely_accessible=True,
)
]
),
),
)
async def _get_podcast_name(self, prov_podcast_id: str) -> str:
"""Return a podcast's name, empty when it cannot be looked up."""
# the podcast lookup is cached, so this is one call per podcast per day at most
try:
return (await self.get_podcast(prov_podcast_id)).name
except (
MediaNotFoundError,
LoginFailed,
ProviderUnavailableError,
ResourceTemporarilyUnavailable,
RetriesExhausted,
) as err:
self.logger.debug("Could not retrieve podcast name for %s: %s", prov_podcast_id, err)
return ""
async def _get_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
"""Return show notes per episode uuid, empty when they cannot be read."""
# show notes are supplementary, so a failure here must never break episode
# resolution. The failure itself is not cached, so the next call tries again.
try:
return await self._fetch_show_notes(prov_podcast_id)
except (
LoginFailed,
ProviderUnavailableError,
ResourceTemporarilyUnavailable,
RetriesExhausted,
) as err:
self.logger.debug("Could not retrieve show notes for %s: %s", prov_podcast_id, err)
return {}
@use_cache(3600 * 24)
async def _fetch_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
"""Return show notes per episode uuid for the given podcast."""
return await self._client.get_show_notes(prov_podcast_id)
def _convert_episode(
self,
episode_data: dict[str, Any],
podcast_uuid: str,
show_notes: dict[str, Any] | None = None,
podcast_name: str = "",
position: int = 0,
) -> PodcastEpisode | None:
"""
Convert episode data to a PodcastEpisode, or None when it carries no episode uuid.
:param position: The episode's listing position. Defaults to 0 (unknown).
"""
episode_uuid = episode_data.get("uuid")
if not episode_uuid:
return None
# this is fed by two endpoints with different field schemas: the full-podcast JSON
# uses snake_case (file_type) while /user/episode uses camelCase (fileType,
# episodeNumber). Neither carries the description or artwork, which is what the
# separate show notes lookup is for.
item_id = f"{podcast_uuid}:{episode_uuid}"
file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
episode_item = PodcastEpisode(
item_id=item_id,
provider=self.instance_id,
name=episode_data.get("title", "Unknown Episode"),
podcast=ItemMapping(
media_type=MediaType.PODCAST,
item_id=podcast_uuid,
provider=self.instance_id,
name=podcast_name,
),
position=position,
provider_mappings={
ProviderMapping(
item_id=item_id,
provider_domain=self.domain,
provider_instance=self.instance_id,
audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
url=episode_data.get("url", ""),
)
},
)
if episode_data.get("duration"):
episode_item.duration = int(episode_data["duration"])
if title := episode_data.get("title"):
episode_item.metadata.label = title
details = show_notes or {}
if description := details.get("description"):
episode_item.metadata.description = description
# only about half the episodes have their own artwork, the rest keep the podcast cover
image_url = details.get("image") or (
f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg"
)
episode_item.metadata.images = UniqueList(
[
MediaItemImage(
type=ImageType.THUMB,
path=image_url,
provider=self.instance_id,
remotely_accessible=True,
)
]
)
return episode_item
def _enrich_episode_with_status(
self,
episode_item: PodcastEpisode,
episode_data: dict[str, Any],
in_progress_map: dict[str | None, dict[str, Any]],
history_map: dict[str | None, dict[str, Any]],
) -> None:
"""
Apply playback status to a PodcastEpisode from the in-progress/history data.
:param episode_item: The episode object to enrich in place.
:param episode_data: Raw episode data dict from the API.
:param in_progress_map: UUID-keyed map of in-progress episode data.
:param history_map: UUID-keyed map of listen-history episode data.
"""
episode_uuid = episode_data.get("uuid")
# history is "recently played", not "completed" - rely on the entry's real progress,
# never on mere history membership. Both fields are always set so the library sync can
# clear a stale completed/resume value (it only updates when both are non-None).
status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
# feeds that omit a duration yield an explicit null rather than a missing key, so
# coerce instead of relying on a dict default
played_up_to = status_data.get("playedUpTo") or 0
duration = status_data.get("duration") or episode_data.get("duration") or 0
completed = status_data.get("playingStatus") == 3 or (
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
)
episode_item.fully_played = completed
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
async def _get_special_folder_episodes(self, folder_name: str) -> list[PodcastEpisode]:
"""
Get episodes for a special browse folder.
:param folder_name: Name of the special folder (up_next, new_releases, etc.)
"""
folder_getters = {
"up_next": self._client.get_up_next_episodes,
"new_releases": self._client.get_new_releases,
"in_progress": self._client.get_in_progress_episodes,
"starred": self._client.get_starred_episodes,
"history": self._client.get_history,
}
episode_list = await folder_getters[folder_name]()
# (episode, podcast uuid, podcast name) per episode, the name empty when the folder
# payload does not carry it
resolved: list[tuple[dict[str, Any], str, str]] = []
for episode_data in episode_list:
# the podcast reference is a string on some endpoints and an object on others
podcast_field = episode_data.get("podcast")
podcast_uuid: str | None
if isinstance(podcast_field, str):
podcast_uuid = podcast_field
elif isinstance(podcast_field, dict):
podcast_uuid = podcast_field.get("uuid")
else:
podcast_uuid = episode_data.get("podcastUuid")
if not podcast_uuid:
continue
# these folders mix podcasts, so the name is not known up front. Take it from the
# payload where that carries it, in either of the two shapes
payload_name = podcast_field.get("title") if isinstance(podcast_field, dict) else None
resolved.append(
(
episode_data,
podcast_uuid,
payload_name or episode_data.get("podcastTitle") or "",
)
)
# every remaining name costs a full-podcast fetch, so look them up once per podcast and
# all at once: serialising them would stall the browse for as long as the folder is deep
missing = list({uuid for _, uuid, name in resolved if not name})
looked_up = await asyncio.gather(*(self._get_podcast_name(uuid) for uuid in missing))
names = dict(zip(missing, looked_up, strict=True))
items: list[PodcastEpisode] = []
for episode_data, podcast_uuid, podcast_name in resolved:
if episode_item := self._convert_episode(
episode_data,
podcast_uuid,
show_notes=None,
podcast_name=podcast_name or names.get(podcast_uuid, ""),
):
items.append(episode_item)
return items
def _create_browse_folders(self) -> list[BrowseFolder]:
"""Create special browse folders for root level."""
folders = [
("up_next", "Up Next"),
("new_releases", "New Releases"),
("in_progress", "In Progress"),
("starred", "Starred"),
("history", "History"),
]
return [
BrowseFolder(
item_id=folder_id,
provider=self.instance_id,
path=f"{self.instance_id}://{folder_id}",
name=name,
image=MediaItemImage(
type=ImageType.THUMB,
path=BROWSE_FOLDER_ICONS[folder_id],
provider=self.instance_id,
remotely_accessible=True,
),
)
for folder_id, name in folders
]
def _get_up_next_playlist(self) -> Playlist:
"""Return the playlist that mirrors the user's Up Next queue."""
return Playlist(
item_id=UP_NEXT_PLAYLIST_ID,
provider=self.instance_id,
name="Up Next",
owner="Pocket Casts",
is_editable=False,
supported_mediatypes={MediaType.PODCAST_EPISODE},
provider_mappings={
ProviderMapping(
item_id=UP_NEXT_PLAYLIST_ID,
provider_domain=self.domain,
provider_instance=self.instance_id,
# every account has its own Up Next queue
is_unique=True,
)
},
metadata=MediaItemMetadata(
images=UniqueList(
[
MediaItemImage(
type=ImageType.THUMB,
path=BROWSE_FOLDER_ICONS[UP_NEXT_PLAYLIST_ID],
provider=self.instance_id,
remotely_accessible=True,
)
]
),
),
)
async def _announce_playback_start(
self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
) -> None:
"""
Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
:param podcast_uuid: The podcast UUID.
:param episode_uuid: The episode UUID.
:param episode: The episode that started playing.
"""
# source the url from the already-loaded item so no extra API call is needed; filtered
# to our own mapping since merged library items can carry other providers' mappings
url = next(
(
mapping.url
for mapping in episode.provider_mappings
if mapping.provider_instance == self.instance_id and mapping.url
),
"",
)
await self._client.play_now(
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
)
await self._client.add_to_history(
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
)
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls45 # {"$imagepolicy": "flux-system:lidarr"}
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
+1 -1
View File
@@ -74,7 +74,7 @@ spec:
# alpine/kubectl is the maintained replacement. Pin the tag
# to the cluster's server version so kubectl is in lock-step
# (mismatched skew can break exec streaming).
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
env:
- name: DATASET_TLV
valueFrom:
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:a1ccea58241c2e0c2e16a55590cdd5651336462c3b4fa26e24a59607e155ef72
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
command:
- bash
- -c
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
initContainers:
- name: prepare-dirs
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
command:
- sh
- -c
+1 -1
View File
@@ -287,7 +287,7 @@ spec:
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
# anchor: bridge-tls-proxy
- name: bridge-tls-proxy
image: alpine/socat:1.8.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
args:
- "-d"
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
+1 -1
View File
@@ -50,7 +50,7 @@ spec:
type: RuntimeDefault
containers:
- name: syncthing
image: syncthing/syncthing:2.1.6@sha256:4d0281bdcb4ed9baf503d251d0544ddd856f49fce3f114056257ae172b7fb670
image: syncthing/syncthing:2.1.7@sha256:2b71da7c0d0a91a6ae5285ad079226fecc0a365d08b27720e1f7037b018f7546
env:
# UI bind: any-interface so the ClusterIP Service can reach
# it. Network exposure is gated by the absence of an Ingress
+1 -1
View File
@@ -59,7 +59,7 @@ spec:
- name: renovate
# Pinned by tag and digest. Renovate updates itself through
# homelab's renovate.json, like any other image here.
image: docker.io/renovate/renovate:44.151.2@sha256:9ea828b38867af0ca5f269e6c5feace9e74e7f035da150bb16f8c5f70bf2e6e8
image: docker.io/renovate/renovate:44.181.8@sha256:b28b2de1dfd35de7e8198756d7f7cdd84ebc28c3f25e80f5b09e598d3faefb2a
env:
- name: RENOVATE_CONFIG_FILE
value: /config/config.json
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-22-1854 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
+152
View File
@@ -0,0 +1,152 @@
# The wiki's schedules. The service runs nothing on its own clock; these jobs
# call its API with the `scheduler` key. Run one now:
# kubectl -n ai create job --from=cronjob/wiki-encyclopedia wiki-encyclopedia-now
# Generated once in-cluster, nothing in OpenBao.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: wiki-scheduler-key
namespace: ai
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- Auth__ApiKeys__scheduler
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wiki-scheduler-key
namespace: ai
spec:
refreshPolicy: CreatedOnce
target:
name: wiki-scheduler-key
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: wiki-scheduler-key
---
# Interval sync; the Gitea push webhook is the fast path.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wiki-sync
namespace: ai
labels:
app.kubernetes.io/name: wiki-sync
spec:
schedule: "*/30 * * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: wiki-sync
spec:
restartPolicy: Never
enableServiceLinks: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: sync
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
- 'curl -fsS --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: wiki-scheduler-key
key: Auth__ApiKeys__scheduler
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
---
# Plans and writes the house encyclopedia (/house). The endpoint syncs first and
# answers 502 when the run fails or the Anthropic API stopped it.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wiki-encyclopedia
namespace: ai
labels:
app.kubernetes.io/name: wiki-encyclopedia
spec:
schedule: "0 6 * * *"
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 11400
ttlSecondsAfterFinished: 604800
template:
metadata:
labels:
app.kubernetes.io/name: wiki-encyclopedia
spec:
restartPolicy: Never
enableServiceLinks: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: run
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
- 'curl -fsS --max-time 10800 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/encyclopedia/run'
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: wiki-scheduler-key
key: Auth__ApiKeys__scheduler
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
+9 -2
View File
@@ -24,7 +24,7 @@ metadata:
namespace: ai
annotations:
# Keys, tokens and the Anthropic key arrive via envFrom; a rotated Secret rolls the pod.
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
secret.reloader.stakater.com/reload: wiki-gitea,wiki-api-keys,wiki-scheduler-key,wiki-typesafe,wiki-anthropic,wiki-api-basic-auth
labels:
app.kubernetes.io/name: wiki
spec:
@@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: wiki
image: gitea.renner.uno/konairius/wiki:build.1792419299 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:build.1793006975 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -77,6 +77,10 @@ spec:
value: https://auth.renner.uno/application/o/wiki/
- name: Auth__Oidc__ClientId
value: wiki
- name: Wiki__Encyclopedia__Enabled
value: "true"
- name: Wiki__Encyclopedia__MaxArticles
value: "0"
# wiki-index BasicAuth users (htpasswd), checked by the service for the read API.
- name: Auth__Basic__UsersPath
value: /var/run/secrets/wiki-basic-auth
@@ -85,6 +89,9 @@ spec:
name: wiki-gitea
- secretRef:
name: wiki-api-keys
# The key the wiki-sync and wiki-encyclopedia CronJobs call with (cronjobs.yaml).
- secretRef:
name: wiki-scheduler-key
- secretRef:
name: wiki-typesafe
- secretRef:
+1
View File
@@ -4,3 +4,4 @@ resources:
- externalsecrets.yaml
- deployment.yaml
- ingress.yaml
- cronjobs.yaml
@@ -69,7 +69,7 @@ spec:
# first build of that day — imagePullPolicy: Always does not help,
# because the tag itself resolves stale. Update this when the image
# is rebuilt.
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
command: ["/bin/sh", "-c"]
args:
- |
@@ -95,7 +95,7 @@ spec:
mountPath: /etc/bird
containers:
- name: bird
image: gitea.renner.uno/gitea_admin/bird@sha256:afd8c3a1fc21a9f2c67af366b3bf77ca9b948f07f66a4977821e4972c8fa6ae4
image: gitea.renner.uno/gitea_admin/bird@sha256:65fc93efead1e9b065d2c78e748f6c3c1ad7216e28b757d9fa4e204fa2ffe9a9
resources:
requests:
cpu: "50m"
@@ -1,74 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
# Delegate renner.uno to the in-cluster k8s_gateway resolver. It
# watches Ingress resources and synthesises A/AAAA records that
# point at the ingress-nginx LB, so pods resolving `auth.renner.uno`,
# `audiobooks.renner.uno`, … stay inside the cluster. Names not
# backed by an in-cluster Ingress fall through inside k8s_gateway
# itself to the UDM (10.2.40.1 / fd00::1), which serves the split-horizon
# renner.uno zone, preserving resolution of LXC/VM + physical services.
# (The old Proxmox DNS VIP 10.2.40.53 is DECOMMISSIONED.) 10.96.0.11 is a
# pinned ClusterIP of the k8s-gateway Service — avoids DNS-name
# resolution during CoreDNS startup.
# Cache tuning mirrors k8s-gateway's (issue #393): the UDM's dnsmasq
# refuses connections in bursts, and because the public renner.uno zone
# has a *.renner.uno wildcard CNAME that matches at every depth, a local
# failure never returns NXDOMAIN — it resolves to the WAN proxy instead.
# Correct answers carry TTL 5 (dnsmasq lease records) while the wrong
# wildcard answer carries TTL 300, so one brief blip pins a wrong answer
# for minutes. `success ... 60` raises the 5s TTL to a 60s floor and
# `serve_stale 1h immediate` keeps serving through an upstream outage.
# NOTE: only cache/serve_stale — the `template` plugin is deliberately NOT
# reintroduced here (it broke cluster DNS in 7a09bad5).
renner.uno:53 {
errors
cache 300 {
success 9984 300 60
denial 9984 300 30
serve_stale 1h immediate
}
forward . 10.96.0.11
loop
reload
}
.:53 {
errors
health {
lameduck 5s
}
ready
log . {
class error
}
prometheus :9153
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
forward . /etc/resolv.conf {
max_concurrent 1000
}
# serve_stale mirrors the renner.uno:53 block (issue #393): when the
# upstream resolver browns out, keep answering from expired cache
# instead of SERVFAILing every public name cluster-wide. Seen on
# 2026-07-30 01:22-01:26Z (~1150 SERVFAILs) — it killed a Velero Kopia
# maintenance job, which runs with backoffLimit 0, on the Hetzner S3
# endpoint (issue #439). cluster.local is exempt from the cache above,
# so in-cluster Service records are never served stale.
cache 30 {
disable success cluster.local
disable denial cluster.local
serve_stale 1h immediate
}
loop
reload
loadbalance
}
@@ -1,5 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# CoreDNS itself (Deployment, Corefile) is a Talos bootstrap manifest; only
# its LoadBalancer Service lives here.
resources:
- configmap.yaml
- service.yaml
@@ -85,10 +85,11 @@ spec:
parameters: "0.0.0.0:9153"
serviceType: ClusterIP
service:
# Pinned so the main CoreDNS Corefile can forward renner.uno
# here without depending on DNS-based Service resolution during
# CoreDNS startup (chicken-and-egg). Chosen from the low, sparsely
# populated end of the service CIDR (kube-dns is 10.96.0.10).
# Pinned because the nodes' resolver names it by address
# (ResolverConfig in the Talos site patches); CoreDNS forwards to the
# node, so every name reaches k8s-gateway that way. Chosen from the
# low, sparsely populated end of the service CIDR (kube-dns is
# 10.96.0.10).
clusterIP: 10.96.0.11
ipFamilyPolicy: PreferDualStack
# The site's upstream resolvers (deployment.dnsPolicy/dnsConfig), from the
@@ -71,7 +71,7 @@ spec:
# socket's parent, and refuses to start if the socket file is left
# over from an unclean shutdown (bind: address already in use).
- name: clean-socket
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
command: ["sh", "-c", "rm -f /run/cni/dhcp.sock"]
volumeMounts:
- name: run-cni
@@ -83,7 +83,7 @@ spec:
# Any image with a shell works: the binary executed here is the
# host's, bind-mounted from /opt/cni/bin. Pinned to the same
# alpine tag the sibling installer uses.
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
command:
- /opt/cni/bin/dhcp
- daemon
@@ -87,11 +87,9 @@ spec:
- port: "53"
protocol: UDP
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
# world: image pulls and llama-server's Hugging Face model download
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
@@ -166,3 +164,76 @@ spec:
- ports:
- port: "8080"
protocol: TCP
---
# Hugging Face model download on cold cache (llama-server, llama-embed, wiki-embed).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: models-egress-huggingface
namespace: ai
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- llama-server
- llama-embed
- wiki-embed
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "huggingface.co"
- matchName: "hf.co"
- matchPattern: "*.huggingface.co"
- matchPattern: "*.hf.co"
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# wiki: Anthropic API (distillation) and TypeSafe API (typed judgements).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wiki-egress-apis
namespace: ai
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: wiki
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "api.anthropic.com"
- matchName: "api.typesafe.ai"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -66,11 +66,9 @@ spec:
- port: "53"
protocol: UDP
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
# world: image pulls, OBC S3 endpoint reachable via the cluster's LB IP, migration pipeline API
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
@@ -94,3 +92,19 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# audiobookshelf (metadata providers) and libation (Audible): HTTPS to the internet.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: audiobookshelf-egress-https
namespace: audiobookshelf
spec:
endpointSelector: {}
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -70,7 +70,6 @@ spec:
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror.
- toEndpoints:
- matchExpressions:
@@ -94,3 +93,43 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# authentik server and worker: SMTP relay (Proton) for mail, HTTPS for
# outbound webhooks, OIDC/social sources and update checks.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: authentik-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/part-of: authentik
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "smtp.protonmail.ch"
toPorts:
- ports:
- port: "587"
protocol: TCP
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -0,0 +1,74 @@
# EGRESS ONLY: an ingress section would flip ingress to default-deny and
# break the apiserver -> webhook admission calls.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-egress
namespace: cert-manager
spec:
endpointSelector: {}
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
---
# Controller: ACME (Let's Encrypt prod + staging), Cloudflare DNS-01 API, and
# DNS-01 self-check against the recursive nameservers.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cert-manager-controller-egress-world
namespace: cert-manager
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/component: controller
app.kubernetes.io/name: cert-manager
egress:
# rules.dns lets Cilium learn the toFQDNs mapping.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "acme-v02.api.letsencrypt.org"
- matchName: "acme-staging-v02.api.letsencrypt.org"
- matchName: "api.cloudflare.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
# Mirrors dns01RecursiveNameservers in
# kubernetes/infrastructure/cert-manager/helmrelease.yaml.
- toCIDR:
- 1.1.1.1/32
- 8.8.8.8/32
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
@@ -0,0 +1,34 @@
# CNPG instance pods: the barman-cloud plugin sidecar archives WAL and base
# backups to Hetzner S3. Additive only: every namespace holding a CNPG cluster
# also carries a namespace egress policy covering in-cluster traffic, so
# selecting these pods here does not cut anything else.
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: cnpg-barman-s3-egress
spec:
endpointSelector:
matchLabels:
cnpg.io/podRole: instance
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "fsn1.your-objectstorage.com"
- matchPattern: "*.fsn1.your-objectstorage.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -24,10 +24,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY: do NOT
# add an egress section to this CNP — that's what broke attempt
# 66dbe9f (it transitioned this ns from default-allow egress to
# egress-enforced-with-only-ICMP, killing DNS / apiserver / world).
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -50,3 +47,39 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
---
# lapi: CAPI + hub index. agent: hub/collection fetch.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: crowdsec-egress-world
namespace: crowdsec
spec:
endpointSelector:
matchLabels:
k8s-app: crowdsec
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -22,8 +22,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY: do NOT
# add an egress section to this CNP.
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -46,3 +45,21 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
@@ -48,4 +48,109 @@ spec:
- toEntities:
- host
- remote-node
---
# external-dns (Cloudflare provider): Cloudflare API.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: external-dns-egress
namespace: apps
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/instance: external-dns
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "api.cloudflare.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# external-dns-unifi: UniFi controller API on the UDM (UNIFI_HOST).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: external-dns-unifi-egress
namespace: apps
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/instance: external-dns-unifi
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "udm.renner.uno"
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# gatus: DNS probes against the UDM resolver by IP; HTTP checks target in-cluster names.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gatus-egress
namespace: apps
spec:
endpointSelector:
matchLabels:
app: gatus
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
---
# ical-proxy: fetches caller-supplied calendar feed URLs.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ical-proxy-egress
namespace: apps
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: ical-proxy
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "80"
protocol: TCP
@@ -92,11 +92,27 @@ spec:
- ports:
- port: "8644"
protocol: TCP
# Push mirror to GitHub and other external targets.
- toEntities:
- host
- remote-node
---
# gitea: push mirrors, external webhooks and OIDC/OAuth sources over HTTPS.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-egress-https
namespace: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# Traefik serves git.renner.uno / gitea.renner.uno (:3000) and the gitea-ssh
# entrypoint (:2222). Lives beside the baseline so both land in one reconcile.
@@ -143,10 +143,6 @@ spec:
- toEntities:
- host
- remote-node
# World: IoT-VLAN devices, HA Cloud, object storage, and third-party
# integration endpoints on the LAN and internet.
- toEntities:
- world
# ICMP — egress mirror of the ingress ICMP allow above. Lets HA
# send ICMP to any cluster pod (covers the kernel's ICMPv6
# Destination Unreachable when CoreDNS responses land on a
@@ -261,10 +257,7 @@ spec:
# hairpin off the home-assistant-bgp LoadBalancer and are unusable from a
# pod. It reaches HA at home-assistant-bgp.home-assistant.svc.cluster.local.
#
# Only the ingress half is needed here. Unlike the openbao rotator, the trmnl
# namespace has no default-deny EGRESS covering this pod — terminus-egress is
# scoped to the Terminus pods by label and does not select the renderer — so
# there is no source-side rule to add.
# The egress half is trmnl-baseline-egress (toEntities: cluster).
#
# Source scoped to the renderer pod specifically, not the whole trmnl
# namespace: Terminus itself sits in that namespace and has no business
@@ -351,3 +344,149 @@ spec:
- ports:
- port: "5432"
protocol: TCP
---
# home-assistant: IoT-VLAN devices, HA Cloud, and third-party integration
# endpoints on the LAN and internet.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: home-assistant-egress-world
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: home-assistant
egress:
- toEntities:
- world
---
# shelly-manager: HTTP RPC to Shelly devices on the IoT VLAN, and mDNS
# queries for their *.local names (sent out the default interface eth0).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: shelly-manager-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: shelly-manager
egress:
- toCIDR:
- 10.2.30.0/24
toPorts:
- ports:
- port: "80"
protocol: TCP
- toCIDR:
- 224.0.0.251/32
toPorts:
- ports:
- port: "5353"
protocol: UDP
---
# esphome: OTA/API to devices on the IoT VLAN; HTTPS for GitHub, PlatformIO and PyPI during builds.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: esphome-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: esphome
egress:
- toCIDR:
- 10.2.30.0/24
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# zigbee2mqtt: Zigbee coordinator (SLZB) TCP socket.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: zigbee2mqtt-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: zigbee2mqtt
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "slzb-zigbee.renner.uno"
toPorts:
- ports:
- port: "6638"
protocol: TCP
---
# mqtt-account-controller: credential delivery to edge-1 (deliver.esp32 in mqtt-accounts.yaml).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: mqtt-account-controller-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: mqtt-account-controller
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "edge-1.iot.renner.uno"
toPorts:
- ports:
- port: "8443"
protocol: TCP
---
# whisper and speech-to-phrase: model downloads over HTTPS.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: speech-models-egress
namespace: home-assistant
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- whisper
- speech-to-phrase
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -33,7 +33,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -56,3 +56,41 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
---
# immich-server and machine-learning: HTTPS to the internet (model downloads, metadata/reverse geocoding, version checks).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: immich-egress-world
namespace: immich
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [immich-server, immich-machine-learning]
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -33,7 +33,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -56,3 +56,43 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
---
# Token job pods install tooling at runtime (apt, dl.k8s.io).
# Selected by the job-controller `job-name` label, so the influxdb StatefulSet pod gets no world egress.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: influxdb-token-jobs-egress-world
namespace: influxdb
spec:
endpointSelector:
matchExpressions:
- key: job-name
operator: Exists
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "80"
protocol: TCP
@@ -9,6 +9,7 @@ resources:
- audiobookshelf-ns.yaml
- home-assistant-ns.yaml
- spegel-ns.yaml
- cert-manager-ns.yaml
- crowdsec-ns.yaml
- database-ns.yaml
- immich-ns.yaml
@@ -37,4 +38,5 @@ resources:
- renovate-ns.yaml
- hermes-ns.yaml
- allow-cilium-health.yaml
- cnpg-barman-s3.yaml
- kube-system-hubble-relay.yaml
@@ -33,7 +33,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -58,8 +58,7 @@ spec:
- { type: 143, family: IPv6 }
---
# Egress for the app pods (synapse, mas, element-web),
# selected by part-of so the mx-pg CNPG pods keep unrestricted egress for
# the barman-cloud WAL archive to Hetzner (same trade as trmnl-ns.yaml).
# selected by part-of; the mx-pg CNPG pods are covered by mx-pg-egress.
#
# Nothing here reaches the internet: that is the network half of
# "federation off". The one exception is synapse-push-egress below.
@@ -187,3 +186,32 @@ spec:
- ports:
- port: "8443"
protocol: TCP
---
# mx-pg CNPG pods: in-cluster + DNS. Unselected by the app egress policies above;
# S3 backup egress comes from the clusterwide CNPG policy.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: mx-pg-egress
namespace: matrix
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: mx-pg
egress:
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
@@ -34,7 +34,7 @@ spec:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale. INGRESS ONLY.
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -57,6 +57,24 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
# All in-cluster destinations (pods, nodes, kube-apiserver).
- toEntities:
- cluster
# rules.dns lets Cilium learn FQDN mappings.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
---
# Hermes Agent (hermes ns) reads metrics, alerts and logs: the Prometheus,
# Alertmanager and Loki HTTP APIs, scoped to those pods and ports.
@@ -84,3 +102,120 @@ spec:
protocol: TCP
- port: "3100"
protocol: TCP
---
# Alertmanager: SMTP submission (smtp_smarthost in helmrelease-prometheus.yaml). Only receiver is email.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: alertmanager-egress-world
namespace: monitoring
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: alertmanager
egress:
# rules.dns lets Cilium learn the toFQDNs mapping.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "smtp.protonmail.ch"
toPorts:
- ports:
- port: "587"
protocol: TCP
---
# Prober: targets are defined by Probe objects (monitoring-rules), any port.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: blackbox-exporter-egress-world
namespace: monitoring
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: prometheus-blackbox-exporter
egress:
- toEntities:
- world
---
# Grafana: plugin download (grafana.com) + OAuth, and the legacy Prometheus datasource.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: grafana-egress-world
namespace: monitoring
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: grafana
egress:
# rules.dns lets Cilium learn the toFQDNs mapping.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
# Datasource "Prometheus (legacy)".
- toFQDNs:
- matchName: "prometheus.srv.renner.uno"
toPorts:
- ports:
- port: "9090"
protocol: TCP
---
# Prometheus: off-site Uptime Kuma scrape (additionalScrapeConfigs job uptime-kuma). No remoteWrite configured.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: prometheus-egress-world
namespace: monitoring
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: prometheus
egress:
# rules.dns lets Cilium learn the toFQDNs mapping.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "kuma.renner.uno"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -24,6 +24,10 @@ spec:
protocol: UDP
- port: "53"
protocol: TCP
# rules.dns lets Cilium learn the toFQDNs mapping
rules:
dns:
- matchPattern: "*"
- toEntities:
- kube-apiserver
- toEndpoints:
@@ -34,9 +38,9 @@ spec:
- ports:
- port: "8200"
protocol: TCP
# NetBird management API (netbird.renner.uno).
- toEntities:
- world
# NetBird management API.
- toFQDNs:
- matchName: "netbird.renner.uno"
toPorts:
- ports:
- port: "443"
@@ -88,7 +88,6 @@ spec:
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror.
- toEndpoints:
- matchExpressions:
@@ -268,3 +267,51 @@ spec:
- ports:
- port: "8000"
protocol: TCP
---
# protonmail-bridge: Proton API (HTTPS) and update/status endpoints (HTTP).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: protonmail-bridge-egress
namespace: paperless
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: protonmail-bridge
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "80"
protocol: TCP
---
# syncthing: relays/discovery (443), STUN (3478, 3479 for the NAT-type
# change request), peer sync (22000), local discovery (21027).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: syncthing-egress
namespace: paperless
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: syncthing
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "3478"
endPort: 3479
protocol: UDP
- port: "22000"
protocol: TCP
- port: "22000"
protocol: UDP
- port: "21027"
protocol: UDP

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