Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 e6e0e44b6a fix(ansible): compare sshd -T output case-insensitively
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The second deploy-ssh-ca run on proxy-1 failed at the assert although
all three settings were active: OpenSSH 10.6 prints them as
TrustedUserCAKeys, AuthorizedPrincipalsFile and RevokedKeys, the check
expected lowercase keywords. The run stopped before the reload, so the
running sshd still has the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:24:41 +01:00
k.renner 39b7fc7fc3 Merge pull request 'fix(ansible): ssh-ca reloads sshd by its real unit name' (#1468) from ssh-ca-reload-fix into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1468
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:09:51 +00:00
claude-botandClaude Opus 5.5 33dd968a25 fix(ansible): reload sshd by its real unit name on every run
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The first deploy to proxy-1 failed at the reload: 'Could not find the
requested service ssh'. proxy-1 is not Debian (banner OpenSSH_10.6
without distro suffix, root shell /usr/bin/bash), so the unit is
sshd.service; the handler only special-cased RedHat.

The unit is now picked from service_facts. The reload runs on every
run instead of via a handler: sshd -T only parses the files, so after
a failed reload a re-run with nothing changed would otherwise leave the
running daemon on the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:05:19 +01:00
k.renner d8813a68b7 Merge pull request 'feat(ansible): hosts trust the OpenBao SSH user CA (SSH CA phase 2)' (#1467) from ssh-ca-phase2 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1467
2026-10-27 08:53:33 +00:00
claude-botandClaude Opus 5.5 9dcaf42f96 fix(ansible): start the SSH CA rollout on proxy-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
claude-ws is decommissioned. proxy-1 resolves by name, answers on 22,
and has proxy-2 as its twin, so the public edge survives a bad run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:50:58 +01:00
security-bot d033503fc3 chore(image-automation): bump gotenberg/gotenberg:8.39.0
Updates managed by image-automation-controller.
2026-10-27 08:50:46 +00:00
claude-botandClaude Opus 5.5 66c56288a7 feat(ansible): hosts trust the OpenBao SSH user CA (phase 2)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New role ssh-ca with play configure-ssh-ca.yml (serial: 1) and pipeline
deploy-ssh-ca. Hosts in the new [ssh_ca] group get:

- TrustedUserCAKeys from OpenBao's public_key endpoint, fetched on the
  controller at run time (no copy in git).
- AuthorizedPrincipalsFile: k.renner for root/ladmin (whichever
  exists), claude-bot for Claude's own claude-bot account.
- RevokedKeys, always deployed: sshd refuses all key logins when the
  file is set but missing.
- claude-bot sudo: read-only journalctl/systemctl status/ss/sshd -T,
  restart/reload only for ssh_ca_claude_sudo_units; NOEXEC against
  pager shell escapes; visudo-validated.

The drop-in is tested with sshd -t and removed again on failure; sshd -T
must show all three settings afterwards. authorized_keys is untouched,
so this only adds a login path. [ssh_ca] starts with claude-ws only.

ssh-sign-owner.hcl now also records the mount, CA and owner role
commands (review on #1466).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:47:48 +01:00
k.renner 2b309dec92 Merge pull request 'feat(openbao): SSH client CA roles (SSH CA phase 1)' (#1466) from ssh-ca-phase1 into main
Reviewed-on: #1466
2026-10-27 08:42:24 +00:00
k.renner 396f839567 Merge pull request 'chore(ci): bump ci-claude image to 2.1.311' (#1464) from ci-image/claude-2.1.311 into main
Reviewed-on: #1464
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 08:42:06 +00:00
claude-botandClaude Opus 5.5 2e412e6448 feat(openbao): SSH client CA roles for owner and claude-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 1 of the SSH CA. The ssh-client-signer mount and its ed25519 CA
were set up by hand (root token); this records the roles and the
claude-bot signing policy.

- role owner: principal k.renner, 6-month certs. The mount was enabled
  with -max-lease-ttl=4380h; the 768h system default would cap it.
- role claude: principal claude-bot, 1h certs, pty only.
- policy ssh-sign-claude for the claude-bot userpass user, next to its
  existing claude-bot-write.

Verified: a YubiKey sk-ssh-ed25519 key signed on role owner yields a
sk-ssh-ed25519-cert-v01 cert, principal k.renner, valid to 2027-04-27.
CA fingerprint SHA256:xEhuqxqFVZ/ig6KpAtHOhLbwsIhyWpzXhvDcjIfM/Js,
served unauthenticated at /v1/ssh-client-signer/public_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:39:19 +01:00
security-bot 7f61ad0ce9 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:50 +00:00
security-bot 6503161146 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:23:47 +00:00
security-bot 01133ca106 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:46 +00:00
security-bot ce5be4d067 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807
Updates managed by image-automation-controller.
2026-10-27 08:17:10 +00:00
security-bot 150e8229fd chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1793088058
Updates managed by image-automation-controller.
2026-10-27 08:07:03 +00:00
security-bot 0247b3dfd5 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:06:48 +00:00
claude-bot 9b7a64e48c chore(ci): bump ci-claude image to 2.1.311
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-27 07:53:48 +00:00
k.renner aee26df4b8 Merge pull request 'fix(wiki): wiki-sync retries while the wiki pod restarts' (#1462) from alert-fix/issue-1461 into main
Reviewed-on: #1462
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 07:53:21 +00:00
k.renner 0bf2cd309e Merge pull request 'feat(openbao): OIDC login via Authentik (SSH CA phase 0)' (#1463) from worktree-openbao-oidc-authentik 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: #1463
2026-10-27 07:52:50 +00:00
claude-bot 73d85b4aa8 Merge remote-tracking branch 'origin/main' into worktree-openbao-oidc-authentik
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/apps/authentik/blueprints-configmap.yaml
2026-10-27 08:45:30 +01:00
claude-botandClaude Opus 5.5 b78cacb6c4 feat(openbao): OIDC login via Authentik for SSH cert signing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Phase 0 of the OpenBao SSH CA: humans log in to OpenBao with
Authentik instead of the root token.

- Authentik blueprint: group OpenBao Admins (k.renner), confidential
  OAuth2 provider openbao (CLI + UI callbacks), application, group
  binding. The client secret is generated by Authentik and only goes
  into auth/oidc/config.
- openbao-authentik-egress: auth.renner.uno resolves to the Traefik
  VIP in-cluster, which the existing world egress does not cover.
- Policy ssh-sign-owner with the manual bao commands for auth/oidc;
  its sign path is enabled in phase 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:44:15 +01:00
security-bot b753ba4776 chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793086240
Updates managed by image-automation-controller.
2026-10-27 07:37:00 +00:00
k.renner 84c0561146 Merge pull request 'feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413' (#1460) from claude/authentik-2026.8.4 into main
Reviewed-on: #1460
2026-10-27 07:28:39 +00:00
hermes-bot be4e605d65 fix(wiki): wiki-sync retry window covers the full 120 s
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Six retries at a fixed 10 s delay stopped after about 60 s, so
--retry-max-time 120 never bound. Twelve retries let the 120 s cap
be the limit, covering a Recreate rollout slowed by an image pull.

Retrying on 5xx is safe: POST /api/v1/sync is serialized by
SyncCoordinator and re-syncs the same state, the same as a webhook
landing mid-sync. --retry-max-time 120 keeps a long sync that fails
late from being repeated.
2026-10-27 07:27:46 +00:00
hermes-bot 10cb1c2152 fix(wiki): wiki-sync retries while the wiki pod restarts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The wiki Deployment uses Recreate, so during an image rollout the Service
has no ready endpoint for ~10 s. wiki-sync-29884710 started 06:30:00Z, 4 s
before the new pod was Ready, failed with curl (7) and fired KubeJobFailed.
Retry up to 6 times, 10 s apart, on connection refused too, bounded to
120 s from the first attempt so a long sync that fails is not repeated.

Refs #1461
2026-10-27 07:26:13 +00:00
claude-botandClaude Opus 5.5 ae8ce26a01 feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Chart, server/worker image and LDAP outpost image move together, as
the chart pin requires. Images built from authentik-samba-bridge
3c8f413 (PR #8), pipeline 43.

Supersedes #1347.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:39:59 +01:00
security-bot 1f1125c49c chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793081757
Updates managed by image-automation-controller.
2026-10-27 06:26:58 +00:00
security-bot 1467596309 chore(image-automation): bump valkey/valkey:9.1.3-alpine
Updates managed by image-automation-controller.
2026-10-27 02:50:38 +00:00
security-bot 8ef130da2e chore(image-automation): bump redis:8.10.3-alpine
Updates managed by image-automation-controller.
2026-10-26 17:50:32 +00:00
security-bot 2e677483db chore(image-automation): bump redis:7.4.12-alpine
Updates managed by image-automation-controller.
2026-10-26 17:46:58 +00:00
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 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
74 changed files with 1517 additions and 190 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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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
+34
View File
@@ -0,0 +1,34 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/ssh-ca/**"
- "ansible/plays/configure-ssh-ca.yml"
- "ansible/inventory/pve_hosts.yml"
- ".woodpecker/deploy-ssh-ca.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
timeout: 30
steps:
- name: deploy-ssh-ca
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/configure-ssh-ca.yml -v
+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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
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.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+6
View File
@@ -69,3 +69,9 @@ edge_host
# home, so it must NOT be used as ansible_host.
[claude_workstation]
claude-ws ansible_host=10.3.40.78 ansible_user=root
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
# Rolled out one host at a time: add the next host after the previous one
# was verified.
[ssh_ca]
proxy-1.renner.uno
+15
View File
@@ -0,0 +1,15 @@
---
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
# certificates (principal claude-bot) for its own claude-bot account with
# limited sudo. Existing authorized_keys stay untouched.
#
# serial: 1 so a host whose sshd rejects the change stops the run before the
# next host is touched.
- name: Configure the SSH user CA
hosts: ssh_ca
become: true
gather_facts: true
serial: 1
roles:
- ssh-ca
+18
View File
@@ -0,0 +1,18 @@
---
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
# it; the public key is fetched from OpenBao on the controller at run time.
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
# Owner certificates (role owner) carry this principal. It is accepted for
# whichever admin login the distro has; users that do not exist are skipped.
ssh_ca_owner_principal: k.renner
ssh_ca_owner_users:
- root
- ladmin
# Claude's own account. Certificates (role claude, 1h) carry the principal.
ssh_ca_claude_user: claude-bot
ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
+2
View File
@@ -0,0 +1,2 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
+139
View File
@@ -0,0 +1,139 @@
---
- name: Fetch the SSH CA public key from OpenBao
ansible.builtin.uri:
url: "{{ ssh_ca_public_key_url }}"
return_content: true
delegate_to: localhost
become: false
run_once: true
register: ssh_ca_key
- name: Check the fetched CA key is an SSH public key
ansible.builtin.assert:
that:
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
run_once: true
- name: Install the trusted user CA key
ansible.builtin.copy:
content: "{{ ssh_ca_key.content | trim }}\n"
dest: /etc/ssh/trusted_user_ca_keys
owner: root
group: root
mode: "0644"
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
- name: Install the revoked keys list
ansible.builtin.copy:
src: revoked_keys
dest: /etc/ssh/revoked_keys
owner: root
group: root
mode: "0644"
- name: Create the Claude account
ansible.builtin.user:
name: "{{ ssh_ca_claude_user }}"
shell: /bin/bash
create_home: true
password: "!"
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
content: "{{ ssh_ca_claude_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0644"
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
- name: Configure sshd for the user CA
block:
- name: Install the sshd drop-in
ansible.builtin.template:
src: ssh-ca.conf.j2
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
owner: root
group: root
mode: "0644"
- name: Test the full sshd config
ansible.builtin.command: sshd -t
changed_when: false
rescue:
- name: Remove the drop-in that broke sshd -t
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
state: absent
- name: Fail the host
ansible.builtin.fail:
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
- name: Read the effective sshd settings
ansible.builtin.command: sshd -T
changed_when: false
register: ssh_ca_effective
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
# on 10.6), so the lines are compared lowercased.
- name: Check sshd uses the CA, principals and revocation list
vars:
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
ansible.builtin.assert:
that:
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian. sshd -T
# only parses the files, so the running daemon is reloaded on every run; a
# reload keeps open sessions.
- name: Read the service list
ansible.builtin.service_facts:
- name: Reload sshd so the running daemon uses the CA config
ansible.builtin.systemd:
name: "{{ 'sshd.service' if 'sshd.service' in ansible_facts.services else 'ssh.service' }}"
state: reloaded
@@ -0,0 +1,4 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# NOEXEC: journalctl/systemctl open a pager, which must not spawn a shell.
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/journalctl, /usr/bin/journalctl *, /usr/bin/systemctl status, /usr/bin/systemctl status *, /usr/bin/systemctl list-units, /usr/bin/systemctl list-units *, /usr/bin/ss -tulpn, /usr/sbin/sshd -T
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
+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
@@ -706,7 +706,7 @@ data:
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
@@ -1093,6 +1093,72 @@ data:
negate: false
failure_result: false
# OpenBao human login (auth/oidc, role owner). Confidential client:
# OpenBao's OIDC method requires a secret. Authentik generates it; it
# lives only in OpenBao's auth/oidc/config, never in git.
- id: openbao-admins-group
model: authentik_core.group
identifiers:
name: OpenBao Admins
state: present
attrs:
is_superuser: false
# Members may log in to OpenBao via OIDC. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: openbao-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: OpenBao
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: openbao
# See the Matrix (MAS) provider: a blueprint-made provider starts
# with no grant types.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
# bao login -method=oidc (CLI listener).
- url: http://localhost:8250/oidc/callback
matching_mode: strict
- url: https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback
matching_mode: strict
# The slug is the issuer path: https://auth.renner.uno/application/o/openbao/
- model: authentik_core.application
identifiers:
slug: openbao
state: present
attrs:
name: OpenBao
slug: openbao
provider: !KeyOf openbao-provider
meta_launch_url: https://openbao.renner.uno
meta_description: "OpenBao secrets"
# Only the OpenBao Admins group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, openbao]]
group: !KeyOf openbao-admins-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-25-1458 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+2 -2
View File
@@ -20,7 +20,7 @@ spec:
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
# floating range drifts the chart ahead of the pinned app image.
version: "2026.8.3"
version: "2026.8.4"
sourceRef:
kind: HelmRepository
name: authentik
@@ -58,7 +58,7 @@ spec:
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.3-9dd92dd"
tag: "2026.8.4-3c8f413"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+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
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$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.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -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
@@ -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.
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
# Any redis major-version bump is a separate migration.
- name: redis
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
@@ -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-25-1459 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -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
@@ -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:
@@ -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
@@ -60,6 +62,30 @@ 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.8 # {"$imagepolicy": "flux-system:music-assistant"}
@@ -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
)
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.38.1 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.3-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+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-25-1455 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Valkey major version tracks upstream's compose.yml (9.x). Sidekiq
# is the only consumer; a major bump is a separate migration.
- name: valkey
image: valkey/valkey:9.1.2-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
image: valkey/valkey:9.1.3-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
args:
- valkey-server
- --save
+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 --retry 12 --retry-delay 10 --retry-connrefused --retry-max-time 120 --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"]
+5 -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.1792939775 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:build.1793086240 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -89,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
@@ -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
@@ -102,6 +102,30 @@ spec:
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
---
# OpenBao server → Authentik (auth.renner.uno) for the auth/oidc method:
# discovery, token exchange and JWKS. auth.renner.uno resolves to the Traefik
# VIP; Cilium applies the DNAT before egress policy, so the rule matches the
# Traefik pods, which `world` does not cover (same path as mas-authentik-egress).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: openbao-authentik-egress
namespace: openbao
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: openbao
component: server
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
---
# Egress complement to home-assistant-rotator-ingress (home-assistant-ns.yaml).
# The openbao-ns default-deny grants TCP egress only to openbao, kube-dns, the
# apiserver/host, and `world`. The Class-B rotators that call EXTERNAL APIs
@@ -288,7 +288,8 @@ spec:
- port: "80"
protocol: TCP
---
# syncthing: relays/discovery (443), STUN (3478), peer sync (22000), local discovery (21027).
# 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:
@@ -306,6 +307,7 @@ spec:
- port: "443"
protocol: TCP
- port: "3478"
endPort: 3479
protocol: UDP
- port: "22000"
protocol: TCP
@@ -0,0 +1,10 @@
# OpenBao policy: ssh-sign-claude
# Sign claude-bot's SSH public key on ssh-client-signer role claude (1h certs). Attached to userpass user claude-bot. NOT applied by Flux.
#
# bao write ssh-client-signer/roles/claude @kubernetes/infrastructure/openbao/ssh-ca/role-claude.json
# bao policy write ssh-sign-claude kubernetes/infrastructure/openbao/policies/ssh-sign-claude.hcl
# bao write auth/userpass/users/claude-bot token_policies=claude-bot-write,ssh-sign-claude
path "ssh-client-signer/sign/claude" {
capabilities = ["update"]
}
@@ -0,0 +1,19 @@
# OpenBao policy: ssh-sign-owner
# Sign the owner's SSH public keys on ssh-client-signer role owner. Bound to auth/oidc role owner. NOT applied by Flux.
#
# bao secrets enable -path=ssh-client-signer -max-lease-ttl=4380h ssh # 768h default would cap the 6-month certs
# bao write ssh-client-signer/config/ca generate_signing_key=true key_type=ed25519
# bao write ssh-client-signer/roles/owner @kubernetes/infrastructure/openbao/ssh-ca/role-owner.json
# bao policy write ssh-sign-owner kubernetes/infrastructure/openbao/policies/ssh-sign-owner.hcl
# bao auth enable oidc
# bao write auth/oidc/config oidc_discovery_url=https://auth.renner.uno/application/o/openbao/ \
# oidc_client_id=openbao oidc_client_secret=<from Authentik> default_role=owner
# bao write auth/oidc/role/owner role_type=oidc user_claim=preferred_username \
# groups_claim=groups oidc_scopes=profile,email bound_claims='{"groups":["OpenBao Admins"]}' \
# allowed_redirect_uris=http://localhost:8250/oidc/callback \
# allowed_redirect_uris=https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback \
# token_policies=ssh-sign-owner token_ttl=1h
path "ssh-client-signer/sign/owner" {
capabilities = ["update"]
}
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -0,0 +1,12 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "claude-bot",
"default_user": "claude-bot",
"allowed_extensions": "permit-pty",
"default_extensions": {
"permit-pty": ""
},
"ttl": "1h",
"max_ttl": "1h"
}
@@ -0,0 +1,14 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "k.renner",
"default_user": "k.renner",
"allowed_extensions": "permit-pty,permit-port-forwarding,permit-agent-forwarding,permit-user-rc",
"default_extensions": {
"permit-pty": "",
"permit-port-forwarding": "",
"permit-user-rc": ""
},
"ttl": "4380h",
"max_ttl": "4380h"
}
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-25 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
+9 -5
View File
@@ -20,8 +20,9 @@ machine:
- 10.2.46.0/24
- fd02:46::/64
# The UDM on the ingress segment. Published here rather than as a route
# because bird owns table 47 and originates the default from it; this is the
# one place the address is stated.
# because bird owns table 47 and originates the default from it. Bird reads
# it from here; k8s-gateway-site below names it again as the pods' resolver,
# because a Talos inline manifest cannot read a node annotation.
nodeAnnotations:
net.renner.uno/egress-gw-v6: fd02:47::1
net.renner.uno/egress-gw-v4: 10.2.47.1
@@ -63,7 +64,10 @@ cluster:
directRoutingDevice: pod0
ipv4NativeRoutingCIDR: 10.0.0.0/8
ipv6NativeRoutingCIDR: fd00::/8
# The site's upstream for k8s-gateway (its pod resolv.conf): the UDM.
# The site's upstream for k8s-gateway (its pod resolv.conf): the UDM on
# the segment pod traffic leaves on, ingress0 (net.renner.uno/egress-gw-*
# above). The UDM's DNS answers out of the interface a query arrived on,
# so its address on another segment gets no reply to a pod source.
- name: k8s-gateway-site
contents: |
apiVersion: v1
@@ -79,8 +83,8 @@ cluster:
dnsPolicy: None
dnsConfig:
nameservers:
- 10.2.40.1
- fd00::1
- fd02:47::1
- 10.2.47.1
---
# All pod traffic goes to table 47, and the table decides — longest prefix
# match, no exceptions to carve out. bird fills it with the node's attached
+1
View File
@@ -31,6 +31,7 @@
"matchPackageNames": [
"apache/tika",
"deluan/navidrome",
"docker.gitea.com/gitea-mcp-server",
"eclipse-mosquitto",
"everythingsmarthome/everything-presence-mmwave-configurator",
"ghcr.io/advplyr/audiobookshelf",