Compare commits

...
Author SHA1 Message Date
security-bot 70a9c66902 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-29
Updates managed by image-automation-controller.
2026-10-29 14:23:57 +00:00
security-bot dcf902f53a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-29-1349
Updates managed by image-automation-controller.
2026-10-29 14:23:54 +00:00
security-bot 83235b2b07 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355
Updates managed by image-automation-controller.
2026-10-29 14:17:20 +00:00
security-bot b656422890 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-29-1346
Updates managed by image-automation-controller.
2026-10-29 14:12:31 +00:00
security-bot 9f5acfd3a6 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-29-1349
Updates managed by image-automation-controller.
2026-10-29 14:06:57 +00:00
security-bot 30db24335e chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29
Updates managed by image-automation-controller.
2026-10-29 13:53:53 +00:00
security-bot 0e33efa3b1 chore(image-automation): bump gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348
Updates managed by image-automation-controller.
2026-10-29 13:53:18 +00:00
security-bot 9fcf5259cb chore(image-automation): bump gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348
Updates managed by image-automation-controller.
2026-10-29 13:53:16 +00:00
security-bot ff6a96438d chore(image-automation): bump gitea.renner.uno/konairius/hermes:2026-10-29-1342
Updates managed by image-automation-controller.
2026-10-29 13:47:37 +00:00
k.renner b741f4bdb0 Merge pull request 'fix(ntp): IPv4 A record and fd02:40::123 for ntp.srv.renner.uno' (#1523) from worktree-ntp-ipv4-record into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1523
2026-10-29 13:41:13 +00:00
claude-botandClaude Opus 5.5 1efbfd1424 fix(ntp): move the IPv6 service address to fd02:40::123
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
fd40::/64 is the legacy VLAN 40 ULA; fd02:40::/64 is current. edge-1
takes RAs on enp1s0.40, so the prefix is on-link there.

The AAAA for ntp.srv.renner.uno moves into OpenTofu next to the A.
Records gain an optional name so one host can carry both types without
re-keying the existing ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:38:04 +01:00
claude-bot 9a061ff4ce Merge remote-tracking branch 'origin/main' into worktree-ntp-ipv4-record 2026-10-29 14:36:46 +01:00
claude-botandClaude Opus 5.5 a593df3bb4 fix(dns): add A for ntp.srv.renner.uno (legacy IPv4 VIP)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ntp.srv.renner.uno carries only the AAAA fd40::123, so IPv4-only clients
behind ntp.renner.uno get no address. The keepalived IPv4 VIP 10.2.40.4
answers NTP; IoT and Server share the Internal zone, where UDP 123 is
allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:31:36 +01:00
security-bot cd39bf648a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-29-1313
Updates managed by image-automation-controller.
2026-10-29 13:23:53 +00:00
k.renner e4362bbd69 Merge pull request 'feat(kuma-sync): declarative notifications via KumaNotification' (#1522) from kuma-notifications into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1522
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-29 13:13:30 +00:00
claude-botandClaude Opus 5.5 63b2c3cc50 feat(kuma-sync): declarative notifications via KumaNotification
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Every Kuma monitor, group or child, carried the default "email"
notification, so one outage sent a down and an up mail per monitor.

- New cluster-scoped CRD KumaNotification (type smtp): from/to in the
  spec, host/port/username/password from a Secret read uncached.
  kuma-sync adopts the Kuma notification of the same name, keeps it
  isDefault=false and reverts drift.
- KumaMonitor gains spec.notificationRefs. A monitor attaches only the
  notifications it references; none means none. The default-notification
  attachment is removed.
- KumaNotification "email" (uptime@renner.uno -> admin@renner.uno) with
  ExternalSecret kuma-sync-smtp from homelab/infrastructure/uptime-kuma/smtp
  and a Role granting get on that one Secret. No monitor references it.
- kuma-setup.js no longer creates the notification; the uptime-kuma role
  and the uptime-kuma-vps policy drop the SMTP credentials
  (policy must be re-applied by hand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:35:59 +01:00
k.renner 6dc27eb31c Merge pull request 'fix(shelly-manager): allow IPv6 HTTP egress to global device addresses' (#1521) from shelly-manager-iot-v6-world-egress into main
Reviewed-on: #1521
2026-10-29 12:33:45 +00:00
claude-botandClaude Opus 5.5 1dfdb104c5 fix(shelly-manager): allow IPv6 HTTP egress to global device addresses
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Gen3/Gen4 Shellies advertise a global address from the delegated IoT
prefix over mDNS. Egress to it was policy-denied, so connects to it hung
until the request timeout instead of reaching the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-10-29 13:30:57 +01:00
k.renner 70bca9ed52 Merge pull request 'feat(ansible): nas-1 and nas-2 trust the SSH user CA' (#1520) from ssh-ca-nas-2 into main
Reviewed-on: #1520
2026-10-29 12:06:19 +00:00
claude-botandClaude Opus 5.5 9cb8e65d85 fix(crowdsec-edge-bouncer): cache the release outside /tmp
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The archive was fetched into the controller's /tmp, so every run after a
/tmp cleanup downloaded it again and reported a change. It now lives in
~/.cache/ansible/crowdsec, like the netbird-client download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:45:57 +01:00
claude-botandClaude Opus 5.5 41cadcb3ac fix(ansible): site.yml runs without warnings or no-op changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
- ssh-ca reloads sshd only when the CA key, the Include or the drop-in
  changed; principals and revoked keys are read per login.
- On DSM the sudo rules are checked with visudo -c -f - on the
  controller instead of a temporary file, which reported three changes
  per run.
- Facts are read through ansible_facts (INJECT_FACTS_AS_VARS
  deprecation) in common, qemu-guest-agent and keepalived-ntp.
- common masks systemd-timesyncd on guests only; a KVM host such as a
  NAS running VMM has no such unit.
- netbird-client uses deb822_repository (apt_repository deprecation) and
  removes the old one-line source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:04:38 +01:00
claude-botandClaude Opus 5.5 4d97dd6e1f feat(ansible): reach nas-1 as root
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
nas-1 trusts the SSH CA now; root accepts the owner certificate there,
as on nas-2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:54:25 +01:00
claude-botandClaude Opus 5.5 6dede6c422 fix(ansible): reach nas-1 as ladmin until it trusts the SSH CA
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
root on nas-1 accepts no owner key or certificate yet, so the first
run has to go through ladmin with its become password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:40:38 +01:00
claude-botandClaude Opus 5.5 3b1bd4cd2e feat(ansible): Synology hosts are reached as root
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
With the SSH CA in place, root accepts the owner certificate on DSM
(PermitRootLogin without-password), so runs need no become password,
as on every other host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:29:59 +01:00
claude-botandClaude Opus 5.5 2c22f66539 feat(ansible): nas-1 back in the inventory
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
nas-1 is powered on again. It joins [synology_nas] and [ssh_ca] like
nas-2; the roles and plays dropped with it in 9c9e45d1 stay dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:27:33 +01:00
claude-botandClaude Opus 5.5 c32dd3bdfd feat(ansible): claude-bot has full sudo on nas-2
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
On DSM claude-bot is in administrators, whose sudo rule asks for a
password nobody knows. ssh_ca_claude_sudo_all (default false) adds a
passwordless rule; synology_nas sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:20:16 +01:00
security-bot 22353bac5a chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-0949
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-29 10:05:30 +00:00
claude-botandClaude Opus 5.5 5c416f8578 feat(ansible): nas-2 trusts the SSH user CA
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
nas-2 (Synology DSM 7.3) joins [ssh_ca] under a new [synology_nas] group,
so claude-bot reaches it with its 1h certificate like every other host.

DSM differs from the fleet in ways the ssh-ca role now handles:
- Python 3.8 only; plays/configure-synology-python.yml installs Synology's
  Python3.9 package via raw, and group_vars point the interpreter at it.
- No getent: admin logins come from /etc/passwd on every host.
- No useradd: the account is made with synouser and added to
  administrators (the only group DSM lets in over SSH). synogroup --member
  replaces the list, so the current members are read and passed along,
  and the run stops if the owner login is not among them.
- No visudo: the sudo rules are rendered and checked on the controller.
- sshd_config has no Include; the role puts it first.
- No sftp/scp for ladmin: transfers are piped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:00:51 +01:00
security-bot 0c37b0d757 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-0807
Updates managed by image-automation-controller.
2026-10-29 08:35:30 +00:00
security-bot d1eb310673 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-0745
Updates managed by image-automation-controller.
2026-10-29 08:05:30 +00:00
security-bot 46f56f1eb8 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-0702
Updates managed by image-automation-controller.
2026-10-29 07:05:30 +00:00
security-bot e1558800a0 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-0606
Updates managed by image-automation-controller.
2026-10-29 06:35:30 +00:00
security-bot 4be6ad740d chore(image-automation): bump twinproduction/gatus:v5.38.0
Updates managed by image-automation-controller.
2026-10-28 21:51:08 +00:00
security-bot b78b705c6c chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.79.0
Updates managed by image-automation-controller.
2026-10-28 20:50:43 +00:00
security-bot 4cbcebf53f chore(image-automation): bump woodpeckerci/woodpecker-server:v3.20.0
Updates managed by image-automation-controller.
2026-10-28 19:54:06 +00:00
security-bot 256801e481 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.20.0
Updates managed by image-automation-controller.
2026-10-28 19:53:33 +00:00
security-bot f3513c0ebe chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-28-1832
Updates managed by image-automation-controller.
2026-10-28 18:35:28 +00:00
k.renner 41f114de58 Merge pull request 'fix(shelly-manager): allow the IoT VLAN ULA prefix in egress' (#1518) from shelly-manager-iot-ipv6-egress into main
Reviewed-on: #1518
2026-10-28 18:29:51 +00:00
claude-botandClaude Opus 5.5 65ea8521c9 fix(shelly-manager): allow the IoT VLAN ULA prefix in egress
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager reached v30iot over eth0 only via IPv4; devices
advertising fd02:30::/64 addresses were blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-10-28 18:52:41 +01:00
security-bot 5071d34bd5 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-28-1639
Updates managed by image-automation-controller.
2026-10-28 16:42:08 +00:00
k.renner ea8aca23e4 Merge pull request 'fix(shelly-manager): firmware endpoint name and bind to the pod addresses' (#1517) from shelly-manager-net1-no-ra into main
Reviewed-on: #1517
2026-10-28 15:54:05 +00:00
claude-botandClaude Opus 5.5 e6d71ba033 fix(shelly-manager): name the firmware endpoint firmware.shelly-manager.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
App endpoints are <function>.<app>.renner.uno (ftp.paperless.renner.uno,
syncthing.paperless.renner.uno).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:51:03 +01:00
claude-botandClaude Opus 5.5 1f835e31b6 fix(shelly-manager): bind the app to the pod addresses, not [::]
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
[::]:8082 also listened on net1. POD_IPS (status.podIPs) lets the app bind
8080/8082/9090 to the pod IPv4 and IPv6 only; ASPNETCORE_URLS falls back
to the pod IPv4 for images that do not read POD_IPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:44:08 +01:00
k.renner 20fcdfe738 Merge pull request 'fix(shelly-manager): no IPv6 router advertisements on the IoT NIC' (#1516) from shelly-manager-net1-no-ra into main
Reviewed-on: #1516
2026-10-28 15:38:04 +00:00
claude-botandClaude Opus 5.5 a8c1710cab fix(shelly-manager): no IPv6 router advertisements on the IoT NIC
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Captured on k8s-1 (cilium-dbg monitor, endpoint 1708): an IPv6 SYN from
edge-1 (fd02:30::/64) to fd99::8a reached the pod on 8082, but no reply
left via eth0; the pod has on-link and default IPv6 routes on net1 from
RAs. sbr only confines IPv4. Turn RA processing off on net1 with the
tuning plugin (as matter-server already uses it) and roll the pod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:27:32 +01:00
k.renner 4d556c1b4b Merge pull request 'fix(shelly-manager): reachable firmware LoadBalancer on IPv4 and IPv6' (#1515) from shelly-firmware-lb-reachable into main
Reviewed-on: #1515
2026-10-28 14:50:51 +00:00
claude-botandClaude Opus 5.5 371f18c85f fix(shelly-manager): reachable firmware LoadBalancer on IPv4 and IPv6
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The auto-assigned IPv4 address 10.2.99.0 timed out from the LAN while
the pod answered on 8082 directly; pin 10.2.99.90/fd99::8a like the
other bgp-pool Services. Kestrel listened on 8082 only on the pod IPv4,
so the IPv6 endpoint had no listener; bind 8082 on [::].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:25:36 +01:00
security-bot 0c56e0dc1a chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-28-1412
Updates managed by image-automation-controller.
2026-10-28 14:18:03 +00:00
k.renner 45befc8af8 Merge pull request 'feat(shelly-manager): serve firmware to the fleet and alert on rollout halts' (#1514) from shelly-manager-local-firmware into main
Reviewed-on: #1514
2026-10-28 14:17:18 +00:00
claude-botandClaude Opus 5.5 1a18755025 feat(shelly-manager): serve firmware to the fleet and alert on rollout halts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager now checks and rolls out Shelly firmware itself
(konairius/shelly-manager#121). Devices download from it on :8082 via a
BGP LoadBalancer named shelly-firmware.renner.uno; it reads Shelly's
index from *.shelly.cloud over HTTPS; /metrics on :9090 is scraped and
alerts on halted rollouts, a stale index and invalid rollout config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:11:19 +01:00
k.renner a2b6456d97 Merge pull request 'feat(music-assistant): Pocket Casts Up Next as a Top Pick' (#1513) from claude/ma-pocketcasts-up-next-top-pick into main
Reviewed-on: #1513
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-28 12:06:04 +00:00
security-bot 6da80f7761 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-28-1038
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-28 11:05:11 +00:00
claude-botandClaude Opus 5.5 9bb5490c33 feat(music-assistant): Pocket Casts Up Next as a Top Pick
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Pocket Casts now serves an Up Next recommendation row whose single item is
the Up Next playlist, titled with the episode at the head of the queue
(podcast as subtitle, podcast artwork). The frontend builds Top Picks for
You from the first items of the visible recommendation rows, so the head of
Up Next shows there; clicking or playing it loads the whole playlist. An
empty Up Next yields an empty row, which the frontend hides together with
the pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:39:17 +01:00
security-bot c58631adcd chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-28
Updates managed by image-automation-controller.
2026-10-28 07:53:53 +00:00
security-bot 011301fdef chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-28-0718
Updates managed by image-automation-controller.
2026-10-28 07:47:13 +00:00
k.renner 4f8443f3e8 Merge pull request 'feat(authentik): Flux image automation tracks the LDAP outpost image' (#1512) from claude/outpost-image-flux into main
Reviewed-on: #1512
2026-10-28 07:38:59 +00:00
claude-botandClaude Opus 5.5 478f39d727 feat(authentik): Flux image automation tracks the LDAP outpost image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The k8s-outposts blueprint pinned the patched LDAP outpost image by hand.
Flux setters cannot patch text inside the blueprint ConfigMap string, and
Gitea's cleanup rule deletes a pinned tag once a newer build ages it out.

The image now lives in the authentik Flux Kustomization's
postBuild.substitute, with the authentik-samba-bridge-ldap-outpost
ImagePolicy marker, and the blueprint reads ${SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE}.
kustomize-controller renders the literal before Authentik reads the file.
`flux build kustomization authentik` differs from the unsubstituted render
only in that line; kubernetes/apps/authentik has no other ${...} literals.

The SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE env var in global.env, used by the
earlier !Env attempt, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:37:17 +01:00
security-bot 86681c9d5b chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-28-0712
Updates managed by image-automation-controller.
2026-10-28 07:36:52 +00:00
security-bot 1f3bdb648a chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-28
Updates managed by image-automation-controller.
2026-10-28 07:23:51 +00:00
security-bot 7989113433 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-28-0712
Updates managed by image-automation-controller.
2026-10-28 07:23:50 +00:00
security-bot 52e935af8b chore(image-automation): bump gitea.renner.uno/konairius/cni-plugins:2026-10-28-0711
Updates managed by image-automation-controller.
2026-10-28 07:23:12 +00:00
security-bot 8be540a8af chore(image-automation): bump gitea.renner.uno/konairius/alpine-jq:2026-10-28-0711 gitea.renner.uno/konairius/alpine-openssl:2026-10-28-0711
Updates managed by image-automation-controller.
2026-10-28 07:23:11 +00:00
k.renner e1092c4c24 Merge pull request 'docs(mosquitto): Shelly MQTT server now set via shelly-manager group settings' (#1508) from docs/mosquitto-shelly-manager-settings-comment into main
Reviewed-on: #1508
2026-10-28 07:18:41 +00:00
k.renner 30fb6dcccd Merge pull request 'fix(authentik): LDAP outpost image back to a literal in the blueprint' (#1511) from claude/outpost-image-literal into main
Reviewed-on: #1511
2026-10-28 07:18:18 +00:00
claude-botandClaude Opus 5.5 0708e7504f fix(authentik): LDAP outpost image back to a literal in the blueprint
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
#1510 set the k8s-outposts blueprint's container_image through
`!Env SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE`. It did not resolve to the patched
image: at 07:07:39Z the outpost controller recreated
ak-outpost-ldap-outpost with Authentik's default
ghcr.io/goauthentik/ldap:2026.8.4, although every worker carries the env
var. The upstream outpost lacks the sambaSamAccount/sambaDomain object
classes DSM's smbd queries for.

container_image is a literal again, on the samba-bridge outpost build that
exists now (2026-10-28-0655).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:16:09 +01:00
security-bot 6060ebdfd3 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-28-0709
Updates managed by image-automation-controller.
2026-10-28 07:12:27 +00:00
security-bot 19968c16fe chore(image-automation): bump gitea.renner.uno/konairius/hermes:2026-10-28-0704
Updates managed by image-automation-controller.
2026-10-28 07:07:12 +00:00
security-bot 5015d41f93 chore(image-automation): bump gitea.renner.uno/konairius/wiki:2026-10-28-0700
Updates managed by image-automation-controller.
2026-10-28 07:05:13 +00:00
security-bot bdf4defe29 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-28-0657
Updates managed by image-automation-controller.
2026-10-28 07:05:11 +00:00
security-bot fa9c7ec965 chore(image-automation): bump gitea.renner.uno/konairius/alpine-jq:2026-10-27 2026-10-28-0655 gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026-10-28-0655 gitea.renner.uno/konairius/mqtt-account-controller:2026-10-28-0657 gitea.renner.uno/konairius/ical-proxy:2026-10-28-0651 gitea.renner.uno/konairius/alpine-openssl:2026-10-27
Updates managed by image-automation-controller.
2026-10-28 07:04:44 +00:00
security-bot 817e61c66c chore(image-automation): bump gitea.renner.uno/konairius/cni-plugins:2026-10-27
Updates managed by image-automation-controller.
2026-10-28 07:04:43 +00:00
security-bot 8386bde784 chore(image-automation): bump gitea.renner.uno/konairius/vsftpd:2026-10-28-0649
Updates managed by image-automation-controller.
2026-10-28 07:04:39 +00:00
k.renner 3248c01d33 Merge pull request 'feat(images): one date tag per in-house konairius build, rolled out by Flux' (#1510) from claude/date-image-tag into main
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1510
2026-10-28 07:03:57 +00:00
claude-botandClaude Opus 5.5 8d9a6955f9 docs(mosquitto): point Shelly MQTT server note at shelly-manager root group settings
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager's /api/v1/mqtt/push is being removed in favour of
group settings (konairius/shelly-manager group-settings M3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 02:12:33 +00:00
92 changed files with 2850 additions and 473 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
# two plays over this group -- withdraw the static assignments fleet-wide, then
# hand the address to keepalived -- and both need the same value. Duplicating a
# service address across plays is exactly the sort of thing that drifts.
ntp_vip6: "fd40::123/128"
ntp_vip6: "fd02:40::123/128"
# Link-local address carried as the FIRST virtual_ipaddress of the IPv6 VRRP
# instance. RFC 5798 gives an IPv6 virtual router a link-local primary, and
@@ -0,0 +1,8 @@
---
# DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this.
ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9
# DSM offers no sftp/scp.
ansible_ssh_transfer_method: piped
# claude-bot is a DSM administrator here; its password is unknown.
ssh_ca_claude_sudo_all: true
+8 -3
View File
@@ -8,8 +8,6 @@
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
# grafana runs in-cluster
# synology_nas (nas-1) powered off; Garage and the PBS client
# it hosted are gone with it
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
@@ -25,6 +23,11 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
[edge_host]
edge-1.iot.renner.uno ansible_user=root
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
[synology_nas]
nas-1.renner.uno ansible_user=root
nas-2.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
# no `when:` on the roles -- moving a stick to another box is a one-line change
@@ -52,7 +55,7 @@ heating_can
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
# A single VRRP member takes the address regardless of its configured state, so
# fd40::123 is still served — verified answering — but by one thin client with
# the service address is still served, but by one thin client with
# no failover behind it. install-ntp-ha.yml still carries the four-host
# ntp_servers priority map, which now describes hosts that do not exist.
#
@@ -70,6 +73,8 @@ proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
nas-1.renner.uno
nas-2.renner.uno
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass);
# everything else needs a CA certificate. Members must be in [ssh_ca].
@@ -0,0 +1,19 @@
---
# Ansible needs Python >= 3.9 on the target; DSM ships 3.8. Synology's
# Python3.9 package installs beside it. raw, because the modules cannot run
# before it is there; raw is skipped in check mode.
- name: Install Python 3.9 on the Synology NAS
hosts: synology_nas
become: true
gather_facts: false
tasks:
- name: Install the Python3.9 package
ansible.builtin.raw: >-
if [ -x {{ ansible_python_interpreter }} ]; then echo present;
else /usr/syno/bin/synopkg install_from_server Python3.9; fi
register: synology_python_install
changed_when: "'present' not in synology_python_install.stdout"
- name: Check the interpreter exists
ansible.builtin.raw: test -x {{ ansible_python_interpreter }}
changed_when: false
+1 -1
View File
@@ -62,7 +62,7 @@
# One node at a time. The keepalived role withdraws the statically assigned
# IPv6 service address and restarts keepalived so the address is re-taken
# under VRRP control; doing that across every NTP server at once would leave
# the whole fleet with nobody holding fd40::123 until the elections settle.
# the whole fleet with nobody holding the service address until the elections settle.
# Serialised, the remaining members keep answering throughout.
serial: 1
@@ -4,7 +4,7 @@
# Networks allowed to use this server as NTP source.
#
# BOTH families are required. IPv6 is the primary address family here -- the
# service address ntp.srv.renner.uno resolves to fd40::123 -- so an IPv4-only
# service address ntp.srv.renner.uno resolves to fd02:40::123 -- so an IPv4-only
# allow list refuses every client that uses the documented name.
#
# The hosts carried `allow fd00::/8` for a long time without it ever being in
+5 -6
View File
@@ -8,12 +8,11 @@
lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s+{{ inventory_hostname }}'
line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
line: "{{ ansible_facts['default_ipv4']['address'] }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
state: present
when:
- ansible_virtualization_type is defined
- ansible_virtualization_type == 'lxc'
- ansible_default_ipv4.address is defined
- ansible_facts['virtualization_type'] == 'lxc'
- ansible_facts['default_ipv4']['address'] is defined
# -------------------------------------------------------
# 1. Install Common Packages
@@ -64,8 +63,8 @@
enabled: no
masked: yes
when:
- ansible_virtualization_type is defined
- ansible_virtualization_type in ['kvm', 'lxc']
- ansible_facts['virtualization_role'] == 'guest'
- ansible_facts['virtualization_type'] in ['kvm', 'lxc']
ignore_errors: yes
# -------------------------------------------------------
@@ -15,6 +15,8 @@
# amd64 asset for exactly this tag.
crowdsec_bouncer_version: v0.0.36
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
# Controller cache for the release archive; it outlives /tmp cleanups.
crowdsec_bouncer_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/crowdsec"
# The LAPI over the public path these hosts already serve, see
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
@@ -32,13 +32,23 @@
# The controller has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host. Also in check mode: the fetch
# only writes on the controller, and the copy below needs the file.
- name: Create the controller download directory
ansible.builtin.file:
path: "{{ crowdsec_bouncer_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
check_mode: false
- name: Fetch the pinned bouncer release on the controller
check_mode: false
ansible.builtin.get_url:
url: >-
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
@@ -53,7 +63,7 @@
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
src: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
+3 -3
View File
@@ -99,7 +99,7 @@
# quietly proceed either. install-edge-host.yml is a separate play with no
# ordering relationship to this one, so nothing guarantees it has been re-run
# to drop the [Address] block. If it has not, edge-1 would end up holding
# fd40::123 statically AND running a VRRP instance for the same address:
# the service address statically AND running a VRRP instance for the same address:
# keepalived would believe it had withdrawn the address while networkd kept it
# assigned, and the host would go on answering NTP with a clock the health
# check had already rejected.
@@ -107,9 +107,9 @@
# That is the precise failure of 2026-08-04, on the host it happened to, so it
# fails fast rather than being left to inspection.
# Matched on the bare address, not the full address/prefixlen, and anchored
# with \b so it cannot also hit a longer address sharing the prefix (fd40::1234).
# with \b so it cannot also hit a longer address sharing the prefix (fd02:40::1234).
#
# Searching for the literal "fd40::123/128" would fail open: a hand-written
# Searching for the literal "fd02:40::123/128" would fail open: a hand-written
# [Address] using any other mask -- /64, say -- would not match, the assert
# below would pass, and keepalived would be handed an address networkd is
# still assigning. Since this check is detect-only, with no delete afterwards
@@ -2,7 +2,7 @@
# Managed by Ansible - Do not edit manually
global_defs {
router_id {{ ansible_hostname }}_ntp
router_id {{ ansible_facts['hostname'] }}_ntp
enable_script_security
script_user root
}
+15 -4
View File
@@ -11,6 +11,8 @@
- curl
- ca-certificates
- gnupg
# deb822_repository
- python3-debian
state: present
update_cache: true
@@ -21,10 +23,19 @@
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
ansible.builtin.deb822_repository:
name: netbird
types: deb
uris: https://pkgs.netbird.io/debian
suites: stable
components: main
signed_by: /usr/share/keyrings/netbird-archive-keyring.gpg
# A one-line netbird.list beside netbird.sources lists the repository twice.
- name: Remove the one-line NetBird repository
ansible.builtin.file:
path: /etc/apt/sources.list.d/netbird.list
state: absent
- name: Install NetBird client
ansible.builtin.apt:
@@ -6,15 +6,15 @@
apt:
name: qemu-guest-agent
state: present
when:
- ansible_os_family is defined and ansible_os_family == 'Debian'
when:
- ansible_facts['os_family'] == 'Debian'
- name: Install QEMU Guest Agent (RHEL/Rocky VMs)
dnf:
name: qemu-guest-agent
state: present
when:
- ansible_os_family is defined and ansible_os_family == 'RedHat'
when:
- ansible_facts['os_family'] == 'RedHat'
- name: Ensure qemu-guest-agent is started and enabled
service:
+3
View File
@@ -16,3 +16,6 @@ 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: []
# Full passwordless sudo for claude-bot on this host.
ssh_ca_claude_sudo_all: false
+4 -12
View File
@@ -1,23 +1,15 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
- name: Look up the admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_admin_lookup
# ssh_ca_admin_logins comes from main.yml, which runs first in the same play.
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item.item }}"
user: "{{ item[0] }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_lookup.results }}"
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
label: "{{ item[0] }}"
+123 -14
View File
@@ -23,6 +23,7 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_trusted_file
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
@@ -34,6 +35,26 @@
group: root
mode: "0644"
# /etc/passwd, not getent: DSM has no getent binary. The admin logins are
# local accounts. Each entry is the split passwd line (0 name, 5 home).
- name: Read the local accounts
ansible.builtin.slurp:
src: /etc/passwd
register: ssh_ca_passwd_file
- name: Find the existing admin logins
vars:
ssh_ca_passwd: "{{ ssh_ca_passwd_file.content | b64decode | trim | split('\n') | map('split', ':') | list }}"
ansible.builtin.set_fact:
ssh_ca_admin_logins: "{{ ssh_ca_passwd | selectattr('0', 'in', ssh_ca_owner_users) | list }}"
ssh_ca_claude_exists: "{{ ssh_ca_claude_user in ssh_ca_passwd | map('first') }}"
# DSM manages accounts with synouser/synogroup and has no useradd.
- name: Detect DSM
ansible.builtin.stat:
path: /usr/syno/sbin/synouser
register: ssh_ca_synouser
# systemd-journal: read every journal without sudo (see sudoers-claude.j2).
- name: Create the Claude account
ansible.builtin.user:
@@ -43,14 +64,57 @@
password: "!"
groups: systemd-journal
append: true
when: not ssh_ca_synouser.stat.exists
- 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 Claude account on DSM
when: ssh_ca_synouser.stat.exists
block:
# synouser requires a password; nobody knows it and sshd refuses passwords.
- name: Create the DSM account
ansible.builtin.command:
argv:
- /usr/syno/sbin/synouser
- --add
- "{{ ssh_ca_claude_user }}"
- "{{ lookup('ansible.builtin.password', '/dev/null', length=40, chars=['ascii_letters', 'digits']) }}"
- Claude Bot
- "0"
- ""
- "0"
# command runs in check mode here.
when:
- not ssh_ca_claude_exists
- not ansible_check_mode
no_log: true
# Members are listed as "<n>:[<name>]".
- name: Read the administrators members
ansible.builtin.command: /usr/syno/sbin/synogroup --get administrators
register: ssh_ca_dsm_admins_out
changed_when: false
check_mode: false
- name: Find the administrators members
ansible.builtin.set_fact:
ssh_ca_dsm_admins: >-
{{ ssh_ca_dsm_admins_out.stdout_lines | select('match', '^[0-9]+:\[.*\]$')
| map('regex_replace', '^[0-9]+:\[(.*)\]$', '\1') | list }}
# synogroup --member replaces the member list; a parse that lost the owner
# admin login would drop it.
- name: Check the member list holds the owner admin login
ansible.builtin.assert:
that:
- ssh_ca_dsm_admins | intersect(ssh_ca_owner_users) | length > 0
fail_msg: "administrators members lack {{ ssh_ca_owner_users }}: {{ ssh_ca_dsm_admins }}"
# DSM allows SSH for administrators only.
- name: Add the Claude account to administrators
ansible.builtin.command:
argv: "{{ ['/usr/syno/sbin/synogroup', '--member', 'administrators'] + ssh_ca_dsm_admins + [ssh_ca_claude_user] }}"
when:
- ssh_ca_claude_user not in ssh_ca_dsm_admins
- not ansible_check_mode
- name: Create the principals directory
ansible.builtin.file:
@@ -63,14 +127,13 @@
- 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 }}"
dest: "/etc/ssh/auth_principals/{{ item[0] }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
label: "{{ item[0] }}"
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
@@ -88,6 +151,48 @@
group: root
mode: "0440"
validate: visudo -cf %s
when: not ssh_ca_synouser.stat.exists
# DSM has no visudo, so the rules are checked on the controller.
- name: Install the Claude sudo rules on DSM
when: ssh_ca_synouser.stat.exists
vars:
ssh_ca_sudoers: "{{ lookup('ansible.builtin.template', 'sudoers-claude.j2') | trim }}"
block:
- name: Check the sudo rules on the controller
ansible.builtin.command:
cmd: visudo -c -f -
stdin: "{{ ssh_ca_sudoers }}"
delegate_to: localhost
become: false
changed_when: false
check_mode: false
- name: Install the checked sudo rules
ansible.builtin.copy:
content: "{{ ssh_ca_sudoers }}\n"
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
- name: Create the sshd drop-in directory
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
# DSM's sshd_config has no Include. sshd keeps the first value it reads, so
# the Include goes first for the drop-ins to win.
- name: Include the sshd drop-in directory
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
validate: sshd -t -f %s
register: ssh_ca_include_line
# 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.
@@ -100,6 +205,7 @@
owner: root
group: root
mode: "0644"
register: ssh_ca_dropin_file
- name: Test the full sshd config
ansible.builtin.command: sshd -t
@@ -136,10 +242,11 @@
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/Ubuntu,
# where sshd.service is an alias that resolves to it. sshd -T only parses the
# files, so the running daemon is reloaded on every run; a reload keeps open
# where sshd.service is an alias that resolves to it. The running daemon is
# reloaded when a file it reads at start changed; a reload keeps open
# sessions. None runs when sshd is socket activated and stopped, or per
# connection (ssh-mesh-only role); the next start reads the config.
# Principals and revoked keys are read per login and need no reload.
# systemctl directly: service_facts took 3-12 s per host.
- name: Find the running sshd unit
ansible.builtin.command: systemctl list-units --state=running --plain --no-legend --type=service sshd.service ssh.service
@@ -161,7 +268,9 @@
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
when: ssh_ca_sshd_units | length == 1
when:
- ssh_ca_sshd_units | length == 1
- ssh_ca_trusted_file is changed or ssh_ca_include_line is changed or ssh_ca_dropin_file is changed
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
+3 -4
View File
@@ -3,11 +3,10 @@
# [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys.
- name: Find the admin logins' authorized_keys
ansible.builtin.stat:
path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_owner_lookup.results }}"
path: "{{ item[5] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
label: "{{ item[0] }}"
register: ssh_ca_keyfiles
- name: Fingerprint the admin logins' authorized keys
@@ -10,3 +10,6 @@
{% 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 %}
{% if ssh_ca_claude_sudo_all %}
{{ ssh_ca_claude_user }} ALL=(ALL) NOPASSWD: ALL
{% endif %}
@@ -5,14 +5,9 @@ uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
# Token with policy uptime-kuma-vps, looked up like the bao CLI does:
# VAULT_TOKEN, else the token helper file written by `bao login -method=oidc`.
+3 -42
View File
@@ -1,4 +1,4 @@
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
// Bootstraps Uptime Kuma: admin user, general settings,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
@@ -20,17 +20,7 @@ const SETTINGS_DEFAULTS = {
trustProxy: false,
};
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
];
const REQUIRED = ["KUMA_SETUP_USERNAME", "KUMA_SETUP_PASSWORD", "KUMA_BASE_URL"];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
@@ -44,7 +34,7 @@ const env = process.env;
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["notificationList", "apiKeyList"];
const LIST_EVENTS = ["apiKeyList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
@@ -123,34 +113,6 @@ async function ensureSettings() {
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
@@ -169,7 +131,6 @@ async function main() {
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await rotateMetricsApiKey();
}
-31
View File
@@ -23,18 +23,6 @@
register: _uptime_kuma_secret_read
no_log: true
- name: Read SMTP credentials from OpenBao
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_smtp_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: _uptime_kuma_smtp_read
no_log: true
# 404 until the role has stored a key for the first time.
- name: Read metrics API key from OpenBao
ansible.builtin.uri:
@@ -52,7 +40,6 @@
- name: Extract the OpenBao secrets
ansible.builtin.set_fact:
_uptime_kuma_secret: "{{ _uptime_kuma_secret_read.json.data.data }}"
_uptime_kuma_smtp: "{{ _uptime_kuma_smtp_read.json.data.data }}"
_uptime_kuma_metrics_key: "{{ _uptime_kuma_metrics_read.json.data.data.api_key if _uptime_kuma_metrics_read.status == 200 else '' }}"
no_log: true
@@ -140,18 +127,6 @@
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
@@ -164,12 +139,6 @@
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
+2
View File
@@ -8,6 +8,8 @@
# One-off actions are not state and stay separate: plays/update-os.yml,
# plays/update-dist-upgrade.yml, plays/rotate-netbird-secrets.yml.
- name: Synology Python
ansible.builtin.import_playbook: plays/configure-synology-python.yml
- name: Base system
ansible.builtin.import_playbook: plays/common.yml
- name: VM time sync
@@ -108,6 +108,17 @@ type KumaMonitorSpec struct {
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
// KumaNotifications attached to the monitor; none attaches no notification.
// +listType=map
// +listMapKey=name
NotificationRefs []NotificationRef `json:"notificationRefs,omitempty"`
}
// NotificationRef references a KumaNotification.
type NotificationRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
type KumaMonitorStatus struct {
@@ -0,0 +1,66 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// SecretRef references a Secret in any namespace.
type SecretRef struct {
// +kubebuilder:validation:MinLength=1
Namespace string `json:"namespace"`
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// SMTPSpec configures an smtp notification. The server and its credentials
// come from the Secret: keys host, port, username and password, all required.
// Port 465 uses implicit TLS, any other port STARTTLS.
type SMTPSpec struct {
// +kubebuilder:validation:MinLength=1
From string `json:"from"`
// +kubebuilder:validation:MinLength=1
To string `json:"to"`
SecretRef SecretRef `json:"secretRef"`
}
// +kubebuilder:validation:XValidation:rule="(self.type == 'smtp') == has(self.smtp)",message="smtp is required for type smtp and forbidden otherwise"
type KumaNotificationSpec struct {
// +kubebuilder:validation:Enum=smtp
Type string `json:"type"`
SMTP *SMTPSpec `json:"smtp,omitempty"`
}
type KumaNotificationStatus struct {
NotificationID int64 `json:"notificationID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// KumaNotification is the Kuma notification named like the resource. It is
// never a default notification and never applied to existing monitors;
// KumaMonitors attach it through notificationRefs.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumanotifications,scope=Cluster
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaNotification struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaNotificationSpec `json:"spec"`
Status KumaNotificationStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaNotificationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaNotification `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaNotification{}, &KumaNotificationList{})
}
@@ -0,0 +1,78 @@
//go:build envtest
package v1alpha1
import (
"context"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func smtpNotification(name string) *KumaNotification {
return &KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: KumaNotificationSpec{Type: "smtp", SMTP: &SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func expectNotificationInvalid(t *testing.T, n *KumaNotification, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), n)
if err == nil || !errors.IsInvalid(err) {
t.Fatalf("expected invalid, got %v", err)
}
for _, w := range wants {
if !strings.Contains(err.Error(), w) {
t.Fatalf("error %q does not contain %q", err.Error(), w)
}
}
}
func TestValidNotification(t *testing.T) {
if err := k8s.Create(context.Background(), smtpNotification("valid-smtp")); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestNotificationInvalidSpecs(t *testing.T) {
noSMTP := smtpNotification("no-smtp")
noSMTP.Spec.SMTP = nil
expectNotificationInvalid(t, noSMTP, "smtp is required for type smtp")
webhook := smtpNotification("webhook")
webhook.Spec.Type = "webhook"
expectNotificationInvalid(t, webhook, "spec.type")
noTo := smtpNotification("no-to")
noTo.Spec.SMTP.To = ""
expectNotificationInvalid(t, noTo, "spec.smtp.to")
noSecret := smtpNotification("no-secret")
noSecret.Spec.SMTP.SecretRef.Namespace = ""
expectNotificationInvalid(t, noSecret, "spec.smtp.secretRef.namespace")
}
func TestMonitorNotificationRefs(t *testing.T) {
m := monitor("with-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "ops"}},
})
if err := k8s.Create(context.Background(), m); err != nil {
t.Fatalf("expected accept, got %v", err)
}
dup := monitor("dup-refs", KumaMonitorSpec{
Type: "group", DisplayName: "g",
NotificationRefs: []NotificationRef{{Name: "mail"}, {Name: "mail"}},
})
expectInvalid(t, dup, "Duplicate value")
empty := monitor("empty-ref", KumaMonitorSpec{
Type: "group", DisplayName: "g", NotificationRefs: []NotificationRef{{Name: ""}},
})
expectInvalid(t, empty, "spec.notificationRefs[0].name")
}
@@ -203,6 +203,11 @@ func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = new(PortSpec)
**out = **in
}
if in.NotificationRefs != nil {
in, out := &in.NotificationRefs, &out.NotificationRefs
*out = make([]NotificationRef, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
@@ -237,6 +242,107 @@ func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotification) DeepCopyInto(out *KumaNotification) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotification.
func (in *KumaNotification) DeepCopy() *KumaNotification {
if in == nil {
return nil
}
out := new(KumaNotification)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotification) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationList) DeepCopyInto(out *KumaNotificationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaNotification, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationList.
func (in *KumaNotificationList) DeepCopy() *KumaNotificationList {
if in == nil {
return nil
}
out := new(KumaNotificationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaNotificationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationSpec) DeepCopyInto(out *KumaNotificationSpec) {
*out = *in
if in.SMTP != nil {
in, out := &in.SMTP, &out.SMTP
*out = new(SMTPSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationSpec.
func (in *KumaNotificationSpec) DeepCopy() *KumaNotificationSpec {
if in == nil {
return nil
}
out := new(KumaNotificationSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaNotificationStatus) DeepCopyInto(out *KumaNotificationStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaNotificationStatus.
func (in *KumaNotificationStatus) DeepCopy() *KumaNotificationStatus {
if in == nil {
return nil
}
out := new(KumaNotificationStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPage) DeepCopyInto(out *KumaStatusPage) {
*out = *in
@@ -365,6 +471,21 @@ func (in *LocalRef) DeepCopy() *LocalRef {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NotificationRef) DeepCopyInto(out *NotificationRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationRef.
func (in *NotificationRef) DeepCopy() *NotificationRef {
if in == nil {
return nil
}
out := new(NotificationRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
*out = *in
@@ -380,6 +501,37 @@ func (in *PortSpec) DeepCopy() *PortSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SMTPSpec) DeepCopyInto(out *SMTPSpec) {
*out = *in
out.SecretRef = in.SecretRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SMTPSpec.
func (in *SMTPSpec) DeepCopy() *SMTPSpec {
if in == nil {
return nil
}
out := new(SMTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretRef) DeepCopyInto(out *SecretRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef.
func (in *SecretRef) DeepCopy() *SecretRef {
if in == nil {
return nil
}
out := new(SecretRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SectionStatus) DeepCopyInto(out *SectionStatus) {
*out = *in
@@ -182,6 +182,22 @@ spec:
format: int32
minimum: 0
type: integer
notificationRefs:
description: KumaNotifications attached to the monitor; none attaches
no notification.
items:
description: NotificationRef references a KumaNotification.
properties:
name:
minLength: 1
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
@@ -0,0 +1,165 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumanotifications.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaNotification
listKind: KumaNotificationList
plural: kumanotifications
singular: kumanotification
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
KumaNotification is the Kuma notification named like the resource. It is
never a default notification and never applied to existing monitors;
KumaMonitors attach it through notificationRefs.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
smtp:
description: |-
SMTPSpec configures an smtp notification. The server and its credentials
come from the Secret: keys host, port, username and password, all required.
Port 465 uses implicit TLS, any other port STARTTLS.
properties:
from:
minLength: 1
type: string
secretRef:
description: SecretRef references a Secret in any namespace.
properties:
name:
minLength: 1
type: string
namespace:
minLength: 1
type: string
required:
- name
- namespace
type: object
to:
minLength: 1
type: string
required:
- from
- secretRef
- to
type: object
type:
enum:
- smtp
type: string
required:
- type
type: object
x-kubernetes-validations:
- message: smtp is required for type smtp and forbidden otherwise
rule: (self.type == 'smtp') == has(self.smtp)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
notificationID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -21,6 +21,7 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors
- kumanotifications
- kumastatuspages
verbs:
- get
@@ -32,6 +33,7 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
- kumanotifications/finalizers
- kumastatuspages/finalizers
verbs:
- update
@@ -39,6 +41,7 @@ rules:
- uptime.renner.uno
resources:
- kumamonitors/status
- kumanotifications/status
- kumastatuspages/status
verbs:
- get
@@ -78,6 +78,11 @@ func NewManager(o Options) (manager.Manager, error) {
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up status page reconciler: %w", err)
}
if err := (&controller.NotificationReconciler{
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Kuma: o.Kuma, Resync: o.Resync,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up notification reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"sync"
"testing"
"time"
@@ -21,6 +20,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
@@ -85,6 +85,9 @@ func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
@@ -411,46 +414,6 @@ func (f *e2e) webhook(name string, isDefault bool) int64 {
return id
}
func TestE2EDefaultNotifications(t *testing.T) {
f, env := newE2E(t)
first := f.webhook("first", true)
f.webhook("opt-in", false)
f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
carry := func(want ...int64) func() (bool, string) {
return func() (bool, string) {
m := byKey(f.owned())
for _, k := range []string{"default/g", "default/probe"} {
got, ok := m[k]
if !ok {
return false, k + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
}
}
return true, ""
}
}
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
second := f.webhook("second", true)
want := []int64{first, second}
slices.Sort(want)
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = nil
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
@@ -17,8 +17,11 @@ import (
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
@@ -27,15 +30,16 @@ import (
const (
Finalizer = "uptime.renner.uno/kuma-sync"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
ReasonNotificationPending = "NotificationPending"
parentRetry = 10 * time.Second
resyncPeriod = 5 * time.Minute
@@ -65,10 +69,13 @@ func (r *Reconciler) resync() time.Duration {
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation.
// metadata.generation. A KumaNotification whose ID changes enqueues the
// monitors referencing it.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged})).
Named("kumamonitor").
Complete(r)
}
@@ -82,18 +89,19 @@ type failure struct {
}
const (
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgNotificationPending = "a referenced notification has no notification ID yet"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -125,15 +133,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
desired.Parent = &p.id
}
notifications, pending, err := r.resolveNotifications(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
if pending {
if err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotificationPending, msgNotificationPending, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: parentRetry}, nil
}
desired.NotificationIDs = notifications
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
notifications, err := r.Kuma.DefaultNotifications(ctx)
if err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
desired.NotificationIDs = notifications
var id int64
if res.mon == nil {
@@ -250,6 +265,57 @@ func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *fai
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
// resolveNotifications returns the Kuma IDs of the referenced
// KumaNotifications. pending reports a reference that does not exist, has no
// notification ID yet or is being deleted. No references yield nil, which
// detaches every notification in Kuma.
func (r *Reconciler) resolveNotifications(ctx context.Context, km *v1.KumaMonitor) (ids []int64, pending bool, err error) {
for _, ref := range km.Spec.NotificationRefs {
kn := &v1.KumaNotification{}
err := r.Client.Get(ctx, types.NamespacedName{Name: ref.Name}, kn)
if apierrors.IsNotFound(err) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
if kn.Status.NotificationID == 0 || !kn.DeletionTimestamp.IsZero() {
return nil, true, nil
}
ids = append(ids, kn.Status.NotificationID)
}
return ids, false, nil
}
// notificationIDChanged reports changes that alter what a referencing
// monitor attaches.
func notificationIDChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaNotification)
n, ok2 := e.ObjectNew.(*v1.KumaNotification)
if !ok1 || !ok2 {
return true
}
return o.Status.NotificationID != n.Status.NotificationID ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// referencingMonitors enqueues the KumaMonitors of all namespaces that
// reference the notification.
func (r *Reconciler) referencingMonitors(ctx context.Context, o client.Object) []reconcile.Request {
var list v1.KumaMonitorList
if err := r.Client.List(ctx, &list); err != nil {
log.FromContext(ctx).Error(err, "list KumaMonitors to enqueue")
return nil
}
var reqs []reconcile.Request
for _, km := range list.Items {
if slices.ContainsFunc(km.Spec.NotificationRefs, func(ref v1.NotificationRef) bool { return ref.Name == o.GetName() }) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
type parentOutcome struct {
id int64
reason string // empty: resolved
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@@ -311,79 +310,6 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
@@ -0,0 +1,240 @@
package controller
import (
"context"
"errors"
"reflect"
"strconv"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
ReasonSecretMissing = "SecretMissing"
ReasonInvalidSecret = "InvalidSecret"
ReasonInvalidSpec = "InvalidSpec"
msgNotificationSynced = "notification in sync"
msgDuplicateNotification = "several notifications carry this name; syncing the lowest ID"
msgSecretMissing = "secret not found or not readable"
msgSecretKeyMissing = "secret lacks host, port, username or password"
msgInvalidSecretPort = "secret port is not a number from 1 to 65535"
msgInvalidType = "notification type is not supported"
// smtpsPort is the port that uses implicit TLS; any other uses STARTTLS.
smtpsPort = 465
)
// smtpSecretKeys are the keys the SMTP Secret must hold, none empty.
var smtpSecretKeys = []string{"host", "port", "username", "password"}
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumanotifications/finalizers,verbs=update
// NotificationReconciler syncs KumaNotification resources to Kuma. The Kuma
// notification is owned by name: the lowest-ID notification with the
// resource's name is adopted, whoever made it.
type NotificationReconciler struct {
Client client.Client
// APIReader reads the SMTP Secret uncached, so no Secret watch is needed:
// only get on the referenced Secret.
APIReader client.Reader
Kuma kumaclient.Notifications
// Resync is how often a resource is reconciled again, which reverts edits
// made in Kuma and picks up Secret changes. 0 means 5 minutes.
Resync time.Duration
}
func (r *NotificationReconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates. Secrets are not watched.
func (r *NotificationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaNotification{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumanotification").
Complete(r)
}
// specProblem is a Ready=False outcome that waits for a resync.
type specProblem struct {
reason string
message string
}
func (r *NotificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
kn := &v1.KumaNotification{}
if err := r.Client.Get(ctx, req.NamespacedName, kn); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !kn.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, kn)
}
if controllerutil.AddFinalizer(kn, Finalizer) {
if err := r.Client.Update(ctx, kn); err != nil {
return ctrl.Result{}, err
}
}
desired, problem, err := r.desired(ctx, kn)
if err != nil {
return ctrl.Result{}, err
}
if problem != nil {
err := r.setReady(ctx, kn, metav1.ConditionFalse, problem.reason, problem.message, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
var id int64
if len(list) == 0 {
id, err = r.Kuma.CreateNotification(ctx, desired)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = list[0].ID
desired.ID = id
if !desired.SameContent(list[0]) {
log.FromContext(ctx).Info("notification differs from spec, updating", "notificationID", id)
if err := r.Kuma.UpdateNotification(ctx, desired); err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(list) > 1 {
ids := make([]int64, 0, len(list))
for _, n := range list {
ids = append(ids, n.ID)
}
log.FromContext(ctx).Error(nil, "several Kuma notifications carry the same name, syncing the lowest ID",
"name", kn.Name, "notificationIDs", ids)
err := r.setReady(ctx, kn, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateNotification, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, kn, metav1.ConditionTrue, ReasonSynced, msgNotificationSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
// reconcileDelete removes every notification carrying the name, duplicates
// included. Kuma detaches them from the monitors.
func (r *NotificationReconciler) reconcileDelete(ctx context.Context, kn *v1.KumaNotification) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(kn, Finalizer) {
return ctrl.Result{}, nil
}
list, err := r.Kuma.ListNotifications(ctx, kn.Name)
if err != nil {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
for _, n := range list {
err := r.Kuma.DeleteNotification(ctx, n.ID)
if err != nil && !errors.Is(err, kumaclient.ErrNotificationNotFound) {
return r.fail(ctx, kn, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(kn, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, kn)
}
// desired builds the notification from the spec and the SMTP Secret. A
// missing or invalid Secret is a problem, not an error; the cause is never
// logged, as it may carry Secret data.
func (r *NotificationReconciler) desired(
ctx context.Context, kn *v1.KumaNotification,
) (kumaclient.Notification, *specProblem, error) {
s := kn.Spec.SMTP
if kn.Spec.Type != "smtp" || s == nil {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSpec, msgInvalidType}, nil
}
secret := &corev1.Secret{}
err := r.APIReader.Get(ctx, types.NamespacedName{Namespace: s.SecretRef.Namespace, Name: s.SecretRef.Name}, secret)
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretMissing}, nil
}
if err != nil {
return kumaclient.Notification{}, nil, err
}
for _, k := range smtpSecretKeys {
if len(secret.Data[k]) == 0 {
return kumaclient.Notification{}, &specProblem{ReasonSecretMissing, msgSecretKeyMissing}, nil
}
}
port, err := strconv.Atoi(strings.TrimSpace(string(secret.Data["port"])))
if err != nil || port < 1 || port > 65535 {
return kumaclient.Notification{}, &specProblem{ReasonInvalidSecret, msgInvalidSecretPort}, nil
}
return kumaclient.Notification{
Name: kn.Name,
Type: "smtp",
Active: true,
SMTP: kumaclient.SMTP{
Host: strings.TrimSpace(string(secret.Data["host"])),
Port: int32(port),
Secure: port == smtpsPort,
Username: string(secret.Data["username"]),
Password: string(secret.Data["password"]),
From: s.From,
To: s.To,
},
}, nil, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *NotificationReconciler) fail(ctx context.Context, kn *v1.KumaNotification, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, kn, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
return ctrl.Result{}, errors.Join(f.err, serr)
}
return ctrl.Result{}, f.err
}
// setReady writes the Ready condition, and the notification ID when given,
// through the status subresource. It writes nothing if the status is
// unchanged.
func (r *NotificationReconciler) setReady(
ctx context.Context, kn *v1.KumaNotification, status metav1.ConditionStatus, reason, msg string, id *int64,
) error {
orig := kn.Status.DeepCopy()
if id != nil {
kn.Status.NotificationID = *id
kn.Status.ObservedGeneration = kn.Generation
}
meta.SetStatusCondition(&kn.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: kn.Generation,
})
if reflect.DeepEqual(*orig, kn.Status) {
return nil
}
return r.Client.Status().Update(ctx, kn)
}
@@ -0,0 +1,278 @@
package controller
import (
"context"
"errors"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// countingNotifs records notification writes on top of the Kuma fake.
type countingNotifs struct {
kumaclient.Notifications
creates, updates, deletes int
}
func (c *countingNotifs) CreateNotification(ctx context.Context, n kumaclient.Notification) (int64, error) {
c.creates++
return c.Notifications.CreateNotification(ctx, n)
}
func (c *countingNotifs) UpdateNotification(ctx context.Context, n kumaclient.Notification) error {
c.updates++
return c.Notifications.UpdateNotification(ctx, n)
}
func (c *countingNotifs) DeleteNotification(ctx context.Context, id int64) error {
c.deletes++
return c.Notifications.DeleteNotification(ctx, id)
}
type notifEnv struct {
r *NotificationReconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *countingNotifs
}
func newNotifEnv(t *testing.T, objs ...client.Object) *notifEnv {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).
WithStatusSubresource(&v1.KumaNotification{}).Build()
kf := kumaclient.NewFake()
cnt := &countingNotifs{Notifications: kf}
return &notifEnv{
r: &NotificationReconciler{Client: k8s, APIReader: k8s, Kuma: cnt},
k8s: k8s, kuma: kf, cnt: cnt,
}
}
func smtpNotification(name string) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@renner.uno", To: "ops@renner.uno",
SecretRef: v1.SecretRef{Namespace: "kuma-sync", Name: "smtp"},
}},
}
}
func smtpSecret(port string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "kuma-sync", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com\n"), "port": []byte(port),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
}
func wantSMTP(name string, port int32, secure bool) kumaclient.Notification {
return kumaclient.Notification{
Name: name, Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: port, Secure: secure,
Username: "kuma", Password: "s3cret", From: "kuma@renner.uno", To: "ops@renner.uno",
},
}
}
func (e *notifEnv) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}})
}
func (e *notifEnv) get(t *testing.T, name string) *v1.KumaNotification {
t.Helper()
kn := &v1.KumaNotification{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Name: name}, kn); err != nil {
t.Fatal(err)
}
return kn
}
func notifReady(t *testing.T, kn *v1.KumaNotification) *metav1.Condition {
t.Helper()
for i := range kn.Status.Conditions {
if kn.Status.Conditions[i].Type == v1.ConditionReady {
return &kn.Status.Conditions[i]
}
}
t.Fatal("no Ready condition")
return nil
}
func TestNotificationCreates(t *testing.T) {
for port, secure := range map[string]bool{"587": false, "465": true, " 25\n": false} {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret(port))
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
kn := e.get(t, "mail")
if kn.Status.NotificationID == 0 || kn.Status.ObservedGeneration != kn.Generation || len(kn.Finalizers) != 1 {
t.Fatalf("resource = %+v", kn)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionTrue || c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
want := wantSMTP("mail", map[string]int32{"587": 587, "465": 465, " 25\n": 25}[port], secure)
want.ID = kn.Status.NotificationID
if got := e.kuma.Notification(kn.Status.NotificationID); got == nil || *got != want {
t.Errorf("port %q: kuma notification = %+v, want %+v", port, got, want)
}
}
}
func TestNotificationAdoptsLowestAndRevertsDrift(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
drifted := wantSMTP("email", 25, false)
drifted.IsDefault = true
drifted.SMTP.Password = "old"
first := e.kuma.PutNotification(drifted)
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 1 {
t.Fatalf("creates=%d updates=%d, want 0 and 1", e.cnt.creates, e.cnt.updates)
}
want := wantSMTP("email", 587, false)
want.ID = first
if got := e.kuma.Notification(first); *got != want {
t.Errorf("kuma notification = %+v, want %+v", *got, want)
}
if got := e.get(t, "email").Status.NotificationID; got != first {
t.Errorf("notificationID = %d, want %d", got, first)
}
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Errorf("updates = %d after a second reconcile, want 1", e.cnt.updates)
}
}
func TestNotificationDuplicateName(t *testing.T) {
e := newNotifEnv(t, smtpNotification("email"), smtpSecret("587"))
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
kn := e.get(t, "email")
if kn.Status.NotificationID != first {
t.Errorf("notificationID = %d, want lowest %d", kn.Status.NotificationID, first)
}
if c := notifReady(t, kn); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.cnt.updates != 0 || e.kuma.Notification(second).SMTP.Port != 25 {
t.Error("only the lowest ID may be synced")
}
}
func TestNotificationSecretProblems(t *testing.T) {
noKey := smtpSecret("587")
delete(noKey.Data, "password")
for name, tc := range map[string]struct {
secret client.Object
reason string
message string
}{
"missing secret": {nil, ReasonSecretMissing, "secret not found or not readable"},
"missing key": {noKey, ReasonSecretMissing, "secret lacks host, port, username or password"},
"bad port": {smtpSecret("smtp"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
"port too large": {smtpSecret("70000"), ReasonInvalidSecret, "secret port is not a number from 1 to 65535"},
} {
objs := []client.Object{smtpNotification("mail")}
if tc.secret != nil {
objs = append(objs, tc.secret)
}
e := newNotifEnv(t, objs...)
res, err := e.reconcile(t, "mail")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
if c := notifReady(t, e.get(t, "mail")); c.Status != metav1.ConditionFalse || c.Reason != tc.reason || c.Message != tc.message {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("%s: creates=%d updates=%d", name, e.cnt.creates, e.cnt.updates)
}
}
}
func TestNotificationKumaErrorNoWrite(t *testing.T) {
e := newNotifEnv(t, smtpNotification("mail"), smtpSecret("587"))
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "mail"); err == nil {
t.Fatal("expected error")
}
if c := notifReady(t, e.get(t, "mail")); c.Reason != ReasonKumaUnavailable || c.Message != msgKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestNotificationDeleteRemovesAll(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
first := e.kuma.PutNotification(wantSMTP("email", 587, false))
second := e.kuma.PutNotification(wantSMTP("email", 25, false))
other := e.kuma.PutNotification(wantSMTP("other", 25, false))
if _, err := e.reconcile(t, "email"); err != nil {
t.Fatal(err)
}
if e.kuma.Notification(first) != nil || e.kuma.Notification(second) != nil {
t.Error("every notification with the name must be deleted")
}
if e.kuma.Notification(other) == nil {
t.Error("other notification deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(kn), &v1.KumaNotification{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestNotificationDeleteKumaErrorKeepsFinalizer(t *testing.T) {
kn := smtpNotification("email")
now := metav1.Now()
kn.DeletionTimestamp = &now
kn.Finalizers = []string{Finalizer}
e := newNotifEnv(t, kn)
e.kuma.SetNotifErr(errors.New("boom"))
if _, err := e.reconcile(t, "email"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "email").Finalizers; len(got) != 1 {
t.Errorf("finalizers = %v", got)
}
}
@@ -0,0 +1,98 @@
//go:build integration
package controller_test
import (
"fmt"
"slices"
"testing"
"time"
"github.com/breml/go-uptime-kuma-client/monitor"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// A KumaNotification adopts the hand-made notification of its name, and only
// monitors referencing it carry it; a default notification is never attached.
func TestE2ENotificationRefs(t *testing.T) {
f, env := newE2E(t)
legacy := f.webhook("legacy", true)
adopted := f.webhook("mail", false)
f.start(env)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "smtp"},
Data: map[string][]byte{
"host": []byte("mail.example.com"), "port": []byte("587"),
"username": []byte("kuma"), "password": []byte("s3cret"),
},
}
kn := &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: "mail"},
Spec: v1.KumaNotificationSpec{Type: "smtp", SMTP: &v1.SMTPSpec{
From: "kuma@example.com", To: "ops@example.com",
SecretRef: v1.SecretRef{Namespace: "default", Name: "smtp"},
}},
}
if err := f.k8s.Create(t.Context(), secret); err != nil {
t.Fatal(err)
}
if err := f.k8s.Create(t.Context(), kn); err != nil {
t.Fatal(err)
}
g := group("g", "")
g.Spec.NotificationRefs = []v1.NotificationRef{{Name: "mail"}}
f.apply(g, dns("probe", "g"))
want := kumaclient.Notification{
ID: adopted, Name: "mail", Type: "smtp", Active: true,
SMTP: kumaclient.SMTP{
Host: "mail.example.com", Port: 587, Username: "kuma", Password: "s3cret",
From: "kuma@example.com", To: "ops@example.com",
},
}
eventually(t, 30*time.Second, "notification adopted and converted", func() (bool, string) {
got, err := f.census.ListNotifications(t.Context(), "mail")
if err != nil {
return false, err.Error()
}
cur := &v1.KumaNotification{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Name: "mail"}, cur); err != nil {
return false, err.Error()
}
if len(got) != 1 || got[0] != want || cur.Status.NotificationID != adopted {
return false, fmt.Sprintf("kuma %+v, status ID %d", got, cur.Status.NotificationID)
}
return true, ""
})
carry := func(key string, want ...int64) func() (bool, string) {
return func() (bool, string) {
got, ok := byKey(f.owned())[key]
if !ok {
return false, key + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", key, got.NotificationIDs, want)
}
return true, ""
}
}
eventually(t, 30*time.Second, "group carries the referenced notification", carry("default/g", adopted))
eventually(t, 30*time.Second, "child carries none", carry("default/probe"))
var live monitor.Group
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/g"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = []int64{legacy}
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications changed by hand restored by a resync", carry("default/g", adopted))
}
@@ -0,0 +1,160 @@
package controller
import (
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
func kumaNotification(name string, id int64) *v1.KumaNotification {
return &v1.KumaNotification{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1.KumaNotificationSpec{Type: "smtp"},
Status: v1.KumaNotificationStatus{NotificationID: id},
}
}
func withRefs(km *v1.KumaMonitor, names ...string) *v1.KumaMonitor {
for _, n := range names {
km.Spec.NotificationRefs = append(km.Spec.NotificationRefs, v1.NotificationRef{Name: n})
}
return km
}
func TestReconcileAttachesReferencedNotifications(t *testing.T) {
group := withRefs(&v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}, "mail", "ops")
e := newEnv(t, withRefs(httpMonitor("a"), "ops"), group, kumaNotification("mail", 7), kumaNotification("ops", 3))
for name, want := range map[string][]int64{"a": {3}, "grp": {3, 7}} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, want) {
t.Errorf("%s: kuma monitor = %+v, want notifications %v", name, got, want)
}
}
}
func TestReconcileNotificationPending(t *testing.T) {
deleted := kumaNotification("going", 5)
now := metav1.Now()
deleted.DeletionTimestamp = &now
deleted.Finalizers = []string{Finalizer}
e := newEnv(t,
withRefs(httpMonitor("missing"), "nope"),
withRefs(httpMonitor("unsynced"), "new"), kumaNotification("new", 0),
withRefs(httpMonitor("deleting"), "going"), deleted,
)
for _, name := range []string{"missing", "unsynced", "deleting"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", name, res.RequeueAfter)
}
c := ready(t, e.get(t, name))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotificationPending ||
c.Message != "a referenced notification has no notification ID yet" {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileNoRefsDetachesNotifications(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
attached := Desired(km)
attached.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), attached)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 {
t.Fatalf("updates = %d", e.cnt.updates)
}
if got := e.kuma.Get(id).NotificationIDs; got != nil {
t.Errorf("notifications = %v, want none", got)
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail")
e := newEnv(t, km, kumaNotification("mail", 1))
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1}) {
t.Errorf("notifications = %v, want [1]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := withRefs(httpMonitor("a"), "mail", "ops")
e := newEnv(t, km, kumaNotification("mail", 4), kumaNotification("ops", 2))
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReferencingMonitors(t *testing.T) {
other := withRefs(httpMonitor("b"), "mail")
other.Namespace = "other"
e := newEnv(t, withRefs(httpMonitor("a"), "ops", "mail"), other, httpMonitor("none"), withRefs(httpMonitor("c"), "ops"))
got := e.r.referencingMonitors(t.Context(), kumaNotification("mail", 1))
want := []reconcile.Request{
{NamespacedName: types.NamespacedName{Namespace: "default", Name: "a"}},
{NamespacedName: types.NamespacedName{Namespace: "other", Name: "b"}},
}
if !slices.Equal(got, want) {
t.Errorf("requests = %v, want %v", got, want)
}
}
func TestNotificationIDChanged(t *testing.T) {
base := kumaNotification("mail", 1)
changed := base.DeepCopy()
changed.Status.NotificationID = 2
cond := base.DeepCopy()
cond.Status.Conditions = []metav1.Condition{{Type: v1.ConditionReady, Status: metav1.ConditionTrue}}
deleting := base.DeepCopy()
now := metav1.Now()
deleting.DeletionTimestamp = &now
for name, tc := range map[string]struct {
n *v1.KumaNotification
want bool
}{
"id changed": {changed, true}, "condition only": {cond, false}, "deleting": {deleting, true},
} {
if got := notificationIDChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: tc.n}); got != tc.want {
t.Errorf("%s: %v, want %v", name, got, tc.want)
}
}
}
@@ -13,7 +13,6 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -47,6 +46,9 @@ type Breml struct {
opts []kuma.Option
token string
backoff0 time.Duration
// notifStale means a notification write may be missing from the
// session's cached list.
notifStale bool
}
var _ Client = (*Breml)(nil)
@@ -158,7 +160,7 @@ func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool
if ctx.Err() != nil {
return false, false
}
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound, ErrNotificationNotFound} {
if errors.Is(err, app) {
return false, false
}
@@ -196,6 +198,8 @@ func (b *Breml) reconnect(ctx context.Context) error {
}
_ = b.c.Disconnect()
b.c = c
// A new session starts with a fresh notification list.
b.notifStale = false
if t := c.SessionToken(); t != "" {
b.token = t
}
@@ -405,29 +409,6 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
@@ -391,51 +391,6 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
@@ -0,0 +1,202 @@
package kumaclient
import (
"cmp"
"context"
"errors"
"fmt"
"maps"
"slices"
"strconv"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
)
const typeSMTP = "smtp"
// ListNotifications reads the notification list cached by the session. Kuma
// has no command to request it: it pushes the list at login and to every
// session of the user after each change. After a write whose broadcast was
// missed the cache is rebuilt first, so a created notification is never
// reported absent.
func (b *Breml) ListNotifications(ctx context.Context, name string) ([]Notification, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.notifStale {
if err := b.c.Resync(ctx); err != nil && !b.recoverSession(ctx, err) {
return nil, fmt.Errorf("resync notifications: %w", err)
}
b.notifStale = false
}
var out []Notification
for _, base := range b.c.GetNotifications(ctx) {
if base.Name != name {
continue
}
n, err := toNotification(base)
if err != nil {
return nil, err
}
out = append(out, n)
}
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
return out, nil
}
// CreateNotification is never retried: Kuma may have stored the notification
// although the ack was lost; the next list finds it by name.
func (b *Breml) CreateNotification(ctx context.Context, n Notification) (int64, error) {
if n.Type != typeSMTP {
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
wire := &notification.SMTP{
Base: notification.Base{Name: n.Name, IsActive: n.Active, IsDefault: n.IsDefault},
}
writeSMTP(&wire.SMTPDetails, n.SMTP)
id, err := b.c.CreateNotification(ctx, wire)
if err != nil {
b.notifStale = true
}
if createdDespite(id, err) {
return id, nil
}
b.recoverSession(ctx, err)
return 0, err
}
func (b *Breml) UpdateNotification(ctx context.Context, n Notification) error {
if n.Type != typeSMTP {
return fmt.Errorf("update notification %d: unsupported type %q", n.ID, n.Type)
}
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.updateNotification(ctx, n) })
return err
}
// updateNotification starts from the notification's current config, so keys
// kuma-sync does not manage are written back unchanged, also across a type
// change.
func (b *Breml) updateNotification(ctx context.Context, n Notification) error {
cur, err := b.c.GetNotification(ctx, n.ID)
if errors.Is(err, kuma.ErrNotFound) {
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
}
if err != nil {
return err
}
var g notification.Generic
if err := cur.As(&g); err != nil {
return fmt.Errorf("notification %d: %w", n.ID, err)
}
g.Name = n.Name
g.IsActive = n.Active
g.IsDefault = n.IsDefault
g.ApplyExisting = false
g.TypeName = typeSMTP
g.GenericDetails = maps.Clone(g.GenericDetails)
if g.GenericDetails == nil {
g.GenericDetails = notification.GenericDetails{}
}
var d notification.SMTPDetails
writeSMTP(&d, n.SMTP)
g.GenericDetails["smtpHost"] = d.Host
g.GenericDetails["smtpPort"] = d.Port
g.GenericDetails["smtpSecure"] = d.Secure
g.GenericDetails["smtpIgnoreSTARTTLS"] = d.IgnoreSTARTTLS
g.GenericDetails["smtpUsername"] = d.Username
g.GenericDetails["smtpPassword"] = d.Password
g.GenericDetails["smtpFrom"] = d.From
g.GenericDetails["smtpTo"] = d.To
err = b.c.UpdateNotification(ctx, g)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
// Applied; only the broadcast is missing.
b.notifStale = true
return nil
}
return err
}
func (b *Breml) DeleteNotification(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, err := b.c.GetNotification(ctx, id); errors.Is(err, kuma.ErrNotFound) {
return struct{}{}, fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
}
err := b.c.DeleteNotification(ctx, id)
if errors.Is(err, kuma.ErrUpdateEventTimeout) {
b.notifStale = true
return struct{}{}, nil
}
return struct{}{}, err
})
return err
}
func writeSMTP(d *notification.SMTPDetails, s SMTP) {
d.Host = s.Host
d.Port = int(s.Port)
d.Secure = s.Secure
d.IgnoreSTARTTLS = s.IgnoreSTARTTLS
d.Username = s.Username
d.Password = s.Password
d.From = s.From
d.To = s.To
}
// toNotification reads the config loosely: a notification made in the Kuma UI
// may hold the port as a string.
func toNotification(base notification.Base) (Notification, error) {
n := Notification{
ID: base.ID, Name: base.Name, Type: base.Type(), IsDefault: base.IsDefault, Active: base.IsActive,
}
if n.Type != typeSMTP {
return n, nil
}
var g notification.Generic
if err := base.As(&g); err != nil {
return Notification{}, fmt.Errorf("notification %d: %w", base.ID, err)
}
d := g.GenericDetails
n.SMTP = SMTP{
Host: configString(d, "smtpHost"),
Port: configPort(d, "smtpPort"),
Secure: configBool(d, "smtpSecure"),
IgnoreSTARTTLS: configBool(d, "smtpIgnoreSTARTTLS"),
Username: configString(d, "smtpUsername"),
Password: configString(d, "smtpPassword"),
From: configString(d, "smtpFrom"),
To: configString(d, "smtpTo"),
}
return n, nil
}
func configString(d notification.GenericDetails, key string) string {
s, _ := d[key].(string)
return s
}
func configBool(d notification.GenericDetails, key string) bool {
v, _ := d[key].(bool)
return v
}
// configPort returns 0 for a missing or unreadable port, which never matches a
// desired one.
func configPort(d notification.GenericDetails, key string) int32 {
switch v := d[key].(type) {
case float64:
return int32(v)
case string:
p, _ := strconv.ParseInt(v, 10, 32)
return int32(p)
}
return 0
}
@@ -21,6 +21,8 @@ var (
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
// ErrStatusPageNotFound means no status page has the slug.
ErrStatusPageNotFound = errors.New("status page not found")
// ErrNotificationNotFound means no notification has the ID.
ErrNotificationNotFound = errors.New("notification not found")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
@@ -196,9 +198,7 @@ type Client interface {
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
StatusPages
Notifications
}
@@ -3,7 +3,6 @@ package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
@@ -21,10 +20,6 @@ type Fake struct {
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
@@ -35,6 +30,11 @@ type Fake struct {
nextGroupID int64
// PageErr, when set, is returned by every status page method.
PageErr error
notifications map[int64]Notification
nextNotifID int64
// NotifErr, when set, is returned by every notification method.
NotifErr error
}
// SetListPartial makes ListOwned return the stored monitors and err.
@@ -50,6 +50,7 @@ func NewFake() *Fake {
return &Fake{
monitors: map[int64]Monitor{}, nextID: 1,
pages: map[string]StatusPage{}, pageGroups: map[int64]string{}, nextPageID: 1, nextGroupID: 1,
notifications: map[int64]Notification{}, nextNotifID: 1,
}
}
@@ -105,32 +106,6 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
@@ -0,0 +1,107 @@
package kumaclient
import (
"cmp"
"context"
"fmt"
"slices"
)
// SetNotifErr makes every notification method return err.
func (f *Fake) SetNotifErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotifErr = err
}
// Notification returns a copy of the stored notification, or nil.
func (f *Fake) Notification(id int64) *Notification {
f.mu.Lock()
defer f.mu.Unlock()
n, ok := f.notifications[id]
if !ok {
return nil
}
return &n
}
// PutNotification stores n as if made in the Kuma UI. A zero ID is assigned
// the next free one.
func (f *Fake) PutNotification(n Notification) int64 {
f.mu.Lock()
defer f.mu.Unlock()
if n.ID == 0 {
n.ID = f.nextNotifID
f.nextNotifID++
} else if n.ID >= f.nextNotifID {
f.nextNotifID = n.ID + 1
}
f.notifications[n.ID] = n
return n.ID
}
func (f *Fake) ListNotifications(_ context.Context, name string) ([]Notification, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return nil, f.NotifErr
}
var out []Notification
for _, n := range f.notifications {
if n.Name == name {
out = append(out, n)
}
}
slices.SortFunc(out, func(a, b Notification) int { return cmp.Compare(a.ID, b.ID) })
return out, nil
}
func (f *Fake) CreateNotification(_ context.Context, n Notification) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return 0, f.NotifErr
}
if n.Type != "smtp" {
return 0, fmt.Errorf("create notification: unsupported type %q", n.Type)
}
n.ID = f.nextNotifID
f.nextNotifID++
f.notifications[n.ID] = n
return n.ID, nil
}
func (f *Fake) UpdateNotification(_ context.Context, n Notification) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return f.NotifErr
}
if _, ok := f.notifications[n.ID]; !ok {
return fmt.Errorf("update notification %d: %w", n.ID, ErrNotificationNotFound)
}
f.notifications[n.ID] = n
return nil
}
// DeleteNotification also detaches the notification from every monitor, as
// Kuma does.
func (f *Fake) DeleteNotification(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotifErr != nil {
return f.NotifErr
}
if _, ok := f.notifications[id]; !ok {
return fmt.Errorf("delete notification %d: %w", id, ErrNotificationNotFound)
}
delete(f.notifications, id)
for mid, m := range f.monitors {
if i := slices.Index(m.NotificationIDs, id); i >= 0 {
m = m.clone()
m.NotificationIDs = slices.Delete(m.NotificationIDs, i, i+1)
f.monitors[mid] = m.Normalized()
}
}
return nil
}
@@ -118,21 +118,6 @@ func TestFakeInjectedErrors(t *testing.T) {
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
@@ -7,7 +7,6 @@ import (
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -182,15 +181,3 @@ func TestNormalizedNotificationIDs(t *testing.T) {
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
}
}
func TestDefaultIDs(t *testing.T) {
got := defaultIDs([]notification.Base{
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
})
if !reflect.DeepEqual(got, []int64{3, 5}) {
t.Errorf("defaultIDs = %v, want [3 5]", got)
}
if got := defaultIDs(nil); got != nil {
t.Errorf("defaultIDs(nil) = %v, want nil", got)
}
}
@@ -0,0 +1,54 @@
package kumaclient
import (
"context"
)
// Notification is the domain view of a Kuma notification. Only smtp is
// modelled; for another type Type is set and SMTP is zero. Config keys
// kuma-sync does not manage are kept as they are in Kuma.
type Notification struct {
// ID is read only.
ID int64
Name string
Type string
IsDefault bool
Active bool
SMTP SMTP
}
// SMTP is the managed part of an smtp notification's config.
type SMTP struct {
Host string
Port int32
// Secure is implicit TLS; false is STARTTLS.
Secure bool
IgnoreSTARTTLS bool
Username string
Password string
From string
To string
}
// SameContent reports whether n and o hold the same managed content. IDs are
// ignored.
func (n Notification) SameContent(o Notification) bool {
n.ID, o.ID = 0, 0
return n == o
}
// Notifications manages Kuma notifications by name. Kuma does not enforce
// unique names.
type Notifications interface {
// ListNotifications returns the notifications named name, by ascending ID.
ListNotifications(ctx context.Context, name string) ([]Notification, error)
// CreateNotification creates the notification and returns its ID. It never
// applies the notification to existing monitors.
CreateNotification(ctx context.Context, n Notification) (int64, error)
// UpdateNotification overwrites the managed fields of notification n.ID.
// It wraps ErrNotificationNotFound for an unknown ID.
UpdateNotification(ctx context.Context, n Notification) error
// DeleteNotification removes the notification and detaches it from every
// monitor. It wraps ErrNotificationNotFound for an unknown ID.
DeleteNotification(ctx context.Context, id int64) error
}
@@ -0,0 +1,108 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"testing"
"time"
"github.com/breml/go-uptime-kuma-client/notification"
)
// eventuallyListed waits until c lists exactly want under name; Kuma pushes
// the list to every session of the user, not only the writer.
func eventuallyListed(t *testing.T, c *Breml, name string, want ...Notification) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
got, err := c.ListNotifications(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if len(got) == len(want) {
same := true
for i := range got {
same = same && got[i] == want[i]
}
if same {
return
}
}
if time.Now().After(deadline) {
t.Fatalf("session lists %+v, want %+v", got, want)
}
time.Sleep(100 * time.Millisecond)
}
}
func TestNotificationRoundTrip(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
n := Notification{
Name: "email", Type: "smtp", Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
id, err := b.CreateNotification(ctx, n)
if err != nil {
t.Fatal(err)
}
n.ID = id
eventuallyListed(t, b, "email", n)
eventuallyListed(t, other, "email", n)
n.SMTP.Port, n.SMTP.Secure, n.SMTP.Password = 465, true, "q"
if err := b.UpdateNotification(ctx, n); err != nil {
t.Fatal(err)
}
eventuallyListed(t, freshClient(t, url), "email", n)
if err := b.DeleteNotification(ctx, id); err != nil {
t.Fatal(err)
}
if err := b.DeleteNotification(ctx, id); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("second delete: %v", err)
}
if err := b.UpdateNotification(ctx, n); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("update deleted: %v", err)
}
eventuallyListed(t, freshClient(t, url), "email")
}
// An adopted notification made in the UI keeps the config kuma-sync does not
// manage, and loses its default flag.
func TestNotificationUpdatePreservesUnmanaged(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
id, err := b.c.CreateNotification(ctx, &notification.SMTP{
Base: notification.Base{Name: "email", IsActive: true, IsDefault: true},
SMTPDetails: notification.SMTPDetails{
Host: "old", Port: 25, From: "f@x", To: "t@x", CustomSubject: "keep me",
},
})
if err != nil {
t.Fatal(err)
}
n := Notification{
ID: id, Name: "email", Type: "smtp", Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
if err := b.UpdateNotification(ctx, n); err != nil {
t.Fatal(err)
}
fresh := freshClient(t, url)
eventuallyListed(t, fresh, "email", n)
var got notification.SMTP
if err := fresh.c.GetNotificationAs(ctx, id, &got); err != nil {
t.Fatal(err)
}
if got.CustomSubject != "keep me" || got.IsDefault {
t.Errorf("customSubject = %q, isDefault = %v, want kept and false", got.CustomSubject, got.IsDefault)
}
}
@@ -0,0 +1,125 @@
package kumaclient
import (
"context"
"encoding/json"
"errors"
"slices"
"testing"
"github.com/breml/go-uptime-kuma-client/notification"
)
// listRow decodes a notification as Kuma sends it in notificationList.
func listRow(t *testing.T, row string) notification.Base {
t.Helper()
var b notification.Base
if err := json.Unmarshal([]byte(row), &b); err != nil {
t.Fatal(err)
}
return b
}
func TestToNotificationSMTP(t *testing.T) {
b := listRow(t, `{"id":3,"name":"email","active":true,"isDefault":true,"userId":1,`+
`"config":"{\"type\":\"smtp\",\"smtpHost\":\"mail\",\"smtpPort\":\"587\",\"smtpSecure\":false,`+
`\"smtpUsername\":\"u\",\"smtpPassword\":\"p\",\"smtpFrom\":\"f@x\",\"smtpTo\":\"t@x\",\"customSubject\":\"s\"}"}`)
got, err := toNotification(b)
if err != nil {
t.Fatal(err)
}
want := Notification{
ID: 3, Name: "email", Type: "smtp", IsDefault: true, Active: true,
SMTP: SMTP{Host: "mail", Port: 587, Username: "u", Password: "p", From: "f@x", To: "t@x"},
}
if got != want {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestToNotificationOtherType(t *testing.T) {
b := listRow(t, `{"id":4,"name":"hook","active":true,"isDefault":false,"userId":1,`+
`"config":"{\"type\":\"webhook\",\"webhookURL\":\"http://x\"}"}`)
got, err := toNotification(b)
if err != nil {
t.Fatal(err)
}
if got != (Notification{ID: 4, Name: "hook", Type: "webhook", Active: true}) {
t.Errorf("got %+v", got)
}
}
func TestNotificationSameContent(t *testing.T) {
a := Notification{ID: 1, Name: "n", Type: "smtp", Active: true, SMTP: SMTP{Host: "h", Port: 25}}
b := a
b.ID = 2
if !a.SameContent(b) {
t.Error("IDs must be ignored")
}
b.SMTP.Password = "x"
if a.SameContent(b) {
t.Error("password change not detected")
}
}
func TestFakeNotifications(t *testing.T) {
ctx := context.Background()
f := NewFake()
other := f.PutNotification(Notification{Name: "email", Type: "webhook"})
id, err := f.CreateNotification(ctx, Notification{Name: "email", Type: "smtp", Active: true})
if err != nil {
t.Fatal(err)
}
f.PutNotification(Notification{Name: "else", Type: "smtp"})
got, err := f.ListNotifications(ctx, "email")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].ID != other || got[1].ID != id {
t.Fatalf("list = %+v, want IDs [%d %d]", got, other, id)
}
if _, err := f.CreateNotification(ctx, Notification{Name: "x", Type: "webhook"}); err == nil {
t.Error("only smtp may be created")
}
up := *f.Notification(id)
up.SMTP.Host = "h"
if err := f.UpdateNotification(ctx, up); err != nil {
t.Fatal(err)
}
if f.Notification(id).SMTP.Host != "h" {
t.Error("not updated")
}
if err := f.UpdateNotification(ctx, Notification{ID: 99, Type: "smtp"}); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("update unknown: %v", err)
}
mid, _ := f.Create(ctx, Monitor{Name: "m", OwnerKey: "ns/m", NotificationIDs: []int64{other, id}})
if err := f.DeleteNotification(ctx, id); err != nil {
t.Fatal(err)
}
if f.Notification(id) != nil {
t.Error("not deleted")
}
if got := f.Get(mid).NotificationIDs; !slices.Equal(got, []int64{other}) {
t.Errorf("monitor notifications = %v, want [%d]", got, other)
}
if err := f.DeleteNotification(ctx, id); !errors.Is(err, ErrNotificationNotFound) {
t.Errorf("delete twice: %v", err)
}
boom := errors.New("boom")
f.SetNotifErr(boom)
if _, err := f.ListNotifications(ctx, "email"); !errors.Is(err, boom) {
t.Errorf("list: %v", err)
}
if _, err := f.CreateNotification(ctx, Notification{Type: "smtp"}); !errors.Is(err, boom) {
t.Errorf("create: %v", err)
}
if err := f.UpdateNotification(ctx, up); !errors.Is(err, boom) {
t.Errorf("update: %v", err)
}
if err := f.DeleteNotification(ctx, other); !errors.Is(err, boom) {
t.Errorf("delete: %v", err)
}
}
@@ -705,9 +705,9 @@ data:
# AND (&(sambaDomainName=...)(objectClass=sambaDomain))
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/konairius/authentik-samba-bridge ; the tag is
# tracked by Flux in helmrelease.yaml global.env.
container_image: !Env SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE
# gitea.renner.uno/konairius/authentik-samba-bridge ; Flux
# substitutes the image from clusters/homelab/authentik.yaml.
container_image: ${SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE}
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
+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-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -11
View File
@@ -50,7 +50,7 @@ spec:
# https://gitea.renner.uno/konairius/authentik-samba-bridge .
image:
repository: gitea.renner.uno/konairius/authentik-samba-bridge-server
tag: "2026.8.4-3c8f413" # {"$imagepolicy": "flux-system:authentik-samba-bridge-server:tag"}
tag: "2026-10-28-0655" # {"$imagepolicy": "flux-system:authentik-samba-bridge-server:tag"}
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -92,10 +92,6 @@ spec:
secretKeyRef:
name: ldap-outpost-tls
key: tls.key
# Patched LDAP outpost image, set via `!Env` in the k8s-outposts
# blueprint (blueprints-configmap.yaml).
- name: SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE
value: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413 # {"$imagepolicy": "flux-system:authentik-samba-bridge-ldap-outpost"}
# Key of the authentik-gate-sync API token, set via `!Env` in the
# gate-sync blueprint (gate-sync.yaml).
- name: AUTHENTIK_GATE_SYNC_TOKEN
@@ -110,7 +106,6 @@ spec:
# breaks. Comma-separated, no spaces (authentik list parsing).
- name: AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
value: "10.244.0.0/16,fd10:244::/48,127.0.0.0/8,::1/128"
authentik:
log_level: info
error_reporting:
@@ -126,7 +121,6 @@ spec:
password: ""
use_tls: true
from: "olymp@renner.uno"
server:
resources:
requests:
@@ -169,7 +163,6 @@ spec:
- secretName: authentik-tls
hosts:
- auth.renner.uno
worker:
resources:
requests:
@@ -186,13 +179,10 @@ spec:
minReplicas: 2
maxReplicas: 4
targetCPUUtilizationPercentage: 70
postgresql:
enabled: false
redis:
enabled: false
# Mount the homelab blueprints (K8s outpost integration, SSO and
# paperless flows) — every key of the ConfigMap is applied by
# the worker on boot and on each apply cycle. See
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
type: RuntimeDefault
containers:
- name: gatus
image: twinproduction/gatus:v5.37.0 # {"$imagepolicy": "flux-system:gatus"}
image: twinproduction/gatus:v5.38.0 # {"$imagepolicy": "flux-system:gatus"}
securityContext:
allowPrivilegeEscalation: false
capabilities:
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$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.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:2026-10-29-1342 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -12,6 +12,8 @@ monitor, and its history starts over.
namespace.
- External targets go in `kubernetes/apps/monitoring/kumamonitors.yaml`. That covers the NetBird
proxies, the Cloudflare-served apex `renner.uno` and third-party sites.
- A monitor alerts only through its `notificationRefs` to cluster-scoped `KumaNotification`
objects (`kubernetes/apps/kuma-sync/notification-*.yaml`). Without refs it sends nothing.
- The CRD lives in `infrastructure/kuma-sync-crds`. Every consuming Flux Kustomization needs
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
@@ -44,6 +44,9 @@ spec:
# reachable over net1. The mDNS resolver is a separate
# ZeroconfResolver socket set and is unaffected.
k8s.v1.cni.cncf.io/networks: shelly-manager-iot
# Changing this rolls the pod, which is the only way a change to the
# shelly-manager-iot attachment takes effect.
net.renner.uno/iot-attachment-config: "tuning-no-ra"
spec:
# eth0 (Cilium-managed pod network) is still the primary — it
# carries the ingress, mosquitto LB return path, etc. The macvlan
@@ -61,11 +64,17 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:build.1793145992 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-10-29-0949 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
# Firmware images only (/fw/...), for the devices: Service shelly-manager-firmware.
- name: firmware
containerPort: 8082
# /metrics only: Service shelly-manager-metrics.
- name: metrics
containerPort: 9090
env:
- name: TZ
value: Europe/Berlin
@@ -83,8 +92,20 @@ spec:
valueFrom:
fieldRef:
fieldPath: status.podIP
# 8082 and 9090 are separate pipelines in the app that answer only
# /fw/... and /metrics; the UI and API stay on 8080.
- name: ASPNETCORE_URLS
value: "http://$(POD_IP):8080"
value: "http://$(POD_IP):8080;http://$(POD_IP):8082;http://$(POD_IP):9090"
# Both pod addresses (IPv4,IPv6). When set, the app binds all three
# ports to exactly these and ignores ASPNETCORE_URLS, so nothing
# listens on net1 and the firmware LoadBalancer gets an IPv6 listener.
- name: POD_IPS
valueFrom:
fieldRef:
fieldPath: status.podIPs
# The URL devices download firmware from: Service shelly-manager-firmware.
- name: Firmware__PublicBaseUrl
value: http://firmware.shelly-manager.renner.uno
- name: ShellyManager__DnsSuffix
value: iot.renner.uno
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
@@ -44,6 +44,12 @@ spec:
# the pod goes NotReady and its ingress returns 504. Static IPAM used to
# dodge this by simply never setting a gateway; leased addresses come with
# one, so it has to be confined instead of omitted.
#
# sbr covers IPv4 only. With Router Advertisements net1 gets global IPv6
# addresses plus on-link and default routes in the main table, and replies
# to IPv6 clients on v30iot leave via net1, bypassing Cilium's LoadBalancer
# reverse NAT. tuning turns RA processing off on net1; the link-local
# address mDNS uses stays.
config: |
{
"cniVersion": "0.3.1",
@@ -57,6 +63,13 @@ spec:
"type": "dhcp"
}
},
{
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.accept_ra": "0",
"net.ipv6.conf.IFNAME.autoconf": "0"
}
},
{
"type": "sbr"
}
@@ -29,3 +29,56 @@ spec:
port: 80
targetPort: api
protocol: TCP
---
# Firmware downloads for the Shelly fleet. Devices on the IoT VLAN reach it over
# the UDM's BGP routes (like mosquitto-iot) by the name
# firmware.shelly-manager.renner.uno, which shelly-manager hands them in the
# update URL.
# The app serves only /fw/... on this port. Ingress: shelly-manager-firmware-
# world-ingress in network-policies/home-assistant-ns.yaml.
apiVersion: v1
kind: Service
metadata:
name: shelly-manager-firmware
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
# Pinned like the other bgp-pool Services, which auto-assignment would give
# the pool's base address 10.2.99.0 (see trmnl/device-service.yaml).
lbipam.cilium.io/ips: "10.2.99.90,fd99::8a"
external-dns.kubernetes.io/hostname: firmware.shelly-manager.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: shelly-manager
ports:
- name: http
port: 80
targetPort: firmware
protocol: TCP
---
# Scrape target for monitoring-rules/shelly-manager-servicemonitor.yaml.
apiVersion: v1
kind: Service
metadata:
name: shelly-manager-metrics
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
app.kubernetes.io/component: metrics
spec:
selector:
app.kubernetes.io/name: shelly-manager
ports:
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
@@ -1,10 +1,11 @@
---
# Mosquitto broker LoadBalancer on the BGP-routed off-link pool. IoT
# devices reach the broker via the UDM's BGP-learned routes — no
# on-link L2 announce on v30iot. Companion Shelly fleet is updated via
# shelly-manager's `/api/v1/mqtt/push` so each device's stored MQTT
# server resolves to `mqtt.renner.uno`, not a bare IP — that way future
# IP changes only need DNS updates, no reflash sweep.
# on-link L2 announce on v30iot. The Shelly fleet's MQTT server is set
# by shelly-manager's root group setting `mqtt.server` (Groups → All
# Shellies → settings) and reconciled to every device, so each device's
# stored MQTT server resolves to `mqtt.renner.uno`, not a bare IP — that
# way future IP changes only need DNS updates, no reflash sweep.
apiVersion: v1
kind: Service
metadata:
@@ -223,7 +223,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/konairius/mqtt-account-controller:main-b1f294d@sha256:8081adfee171c36cfa83734e5e7ccc3aad3debf1c0154488de70143674cde85f # {"$imagepolicy": "flux-system:mqtt-account-controller"}
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-10-28-0657 # {"$imagepolicy": "flux-system:mqtt-account-controller"}
env:
- name: BROKER_URL
value: ssl://mosquitto.home-assistant.svc.cluster.local:8884
@@ -66,7 +66,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path when it falls through — so the path has to
# exist on both registries. Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
+1 -1
View File
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: ical-proxy
image: gitea.renner.uno/konairius/ical-proxy:main-926ead0 # {"$imagepolicy": "flux-system:ical-proxy"}
image: gitea.renner.uno/konairius/ical-proxy:2026-10-28-0651 # {"$imagepolicy": "flux-system:ical-proxy"}
ports:
- containerPort: 8080
name: http
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-29-1349 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -21,3 +21,34 @@ spec:
remoteRef:
key: infrastructure/kuma-sync
property: password
---
# SMTP submission for the KumaNotification "email"; OpenBao homelab/infrastructure/uptime-kuma/smtp.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: kuma-sync-smtp
data:
- secretKey: host
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: server
- secretKey: port
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: port
- secretKey: username
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: username
- secretKey: password
remoteRef:
key: infrastructure/uptime-kuma/smtp
property: token
@@ -5,3 +5,4 @@ resources:
- rbac.yaml
- deployment.yaml
- statuspage-renner.yaml
- notification-email.yaml
@@ -0,0 +1,14 @@
---
# Referenced by KumaMonitor spec.notificationRefs; unreferenced, it sends nothing.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaNotification
metadata:
name: email
spec:
type: smtp
smtp:
from: uptime@renner.uno
to: admin@renner.uno
secretRef:
namespace: kuma-sync
name: kuma-sync-smtp
+30 -4
View File
@@ -7,20 +7,20 @@ metadata:
labels:
app.kubernetes.io/name: kuma-sync
---
# Cluster-wide: KumaMonitor and KumaStatusPage objects live in any namespace.
# Cluster-wide: KumaMonitor and KumaStatusPage objects live in any namespace; KumaNotification is cluster-scoped.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors", "kumastatuspages"]
resources: ["kumamonitors", "kumastatuspages", "kumanotifications"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/status", "kumastatuspages/status"]
resources: ["kumamonitors/status", "kumastatuspages/status", "kumanotifications/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/finalizers", "kumastatuspages/finalizers"]
resources: ["kumamonitors/finalizers", "kumastatuspages/finalizers", "kumanotifications/finalizers"]
verbs: ["update"]
- apiGroups: [""]
resources: ["events"]
@@ -41,3 +41,29 @@ subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
---
# SMTP credentials of the KumaNotification "email", read uncached.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["kuma-sync-smtp"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kuma-sync-smtp
namespace: kuma-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: kuma-sync-smtp
subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
@@ -63,6 +63,9 @@ resources:
# deadlocked the whole apps layer. See the file for the cycle.
- rook-servicemonitors.yaml
- home-assistant-servicemonitor.yaml
# shelly-manager firmware rollout: halts, index freshness, config.
- shelly-manager-servicemonitor.yaml
- prometheusrule-shelly-manager.yaml
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
@@ -0,0 +1,54 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shelly-manager
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: shelly-manager.rules
rules:
# Metrics from shelly-manager's /metrics (ServiceMonitor
# shelly-manager-servicemonitor.yaml). Tests: tests/shelly-manager_test.yaml.
- alert: ShellyFirmwareRolloutHalted
# 1 per device type while its rollout is halted; cleared by Resume or
# Skip version on the Firmware page.
expr: |
max by (type) (shelly_manager_firmware_halted) == 1
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware rollout halted for {{ $labels.type }}"
description: "A firmware update of a {{ $labels.type }} device failed, so shelly-manager stopped rolling out to that type. The Firmware page names the device and the reason; Resume retries that device, Skip version leaves the version out."
- alert: ShellyFirmwareIndexStale
# Set only by a refresh in which every index source (Gen1 and Gen2+)
# succeeded, and restored from the stored releases on start. The
# index is read every 6 h.
expr: |
time() - max(shelly_manager_firmware_index_last_success_timestamp_seconds) > 172800
for: 30m
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware index not read successfully for 48 h"
description: "shelly-manager has not completed a refresh of all firmware index sources for 48 hours, so new firmware is not picked up. Check its logs for index errors (TLS pin, egress to *.shelly.cloud, HTTP errors); shelly_manager_firmware_index_errors_total shows which source fails."
- alert: ShellyFirmwareConfigInvalid
# Firmware:Window or Firmware:TimeZone cannot be read; the rollout
# does not start while this is 0.
expr: |
max(shelly_manager_firmware_config_valid) == 0
for: 10m
labels:
severity: warning
namespace: home-assistant
annotations:
summary: "Shelly firmware rollout disabled by invalid config"
description: "Firmware__Window or Firmware__TimeZone in kubernetes/apps/home-assistant-iot/shelly-manager-deployment.yaml (or the image defaults) is invalid, so shelly-manager starts no automatic firmware updates."
@@ -0,0 +1,19 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: shelly-manager
namespace: home-assistant
labels:
app.kubernetes.io/name: shelly-manager
# Not required for discovery — serviceMonitorSelector is empty — but kept
# for consistency with the other monitors.
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: shelly-manager
app.kubernetes.io/component: metrics
endpoints:
- port: metrics
path: /metrics
interval: 60s
@@ -34,6 +34,7 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
| `uptime-kuma_test.yaml` | Hand-written suite for `prometheusrule-uptime-kuma.yaml` and the kuma exclusion in `prometheusrule-blackbox.yaml`. |
| `k8s-gateway_test.yaml` | Hand-written suite for `prometheusrule-k8s-gateway.yaml` (#1290). |
| `shelly-manager_test.yaml` | Hand-written suite for `prometheusrule-shelly-manager.yaml`. |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
@@ -0,0 +1,80 @@
# Suite for prometheusrule-shelly-manager.yaml.
rule_files:
- ../prometheusrule-shelly-manager.yaml
evaluation_interval: 1m
tests:
# A halted type fires at once, per type; a type at 0 stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_halted{type="rpc:Plus1PM",pod="shelly-manager-a"}'
values: '0 0 1 1 1'
- series: 'shelly_manager_firmware_halted{type="gen1:SHSW-1",pod="shelly-manager-a"}'
values: '0 0 0 0 0'
alert_rule_test:
- eval_time: 1m
alertname: ShellyFirmwareRolloutHalted
exp_alerts: []
- eval_time: 2m
alertname: ShellyFirmwareRolloutHalted
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
type: rpc:Plus1PM
exp_annotations:
summary: "Shelly firmware rollout halted for rpc:Plus1PM"
description: "A firmware update of a rpc:Plus1PM device failed, so shelly-manager stopped rolling out to that type. The Firmware page names the device and the reason; Resume retries that device, Skip version leaves the version out."
# Last success at t=0: stale from 48h01m (first evaluation past 172800 s),
# fires 30 m later.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_index_last_success_timestamp_seconds{pod="shelly-manager-a"}'
values: '0+0x3000'
alert_rule_test:
- eval_time: 48h
alertname: ShellyFirmwareIndexStale
exp_alerts: []
- eval_time: 48h30m
alertname: ShellyFirmwareIndexStale
exp_alerts: []
- eval_time: 48h31m
alertname: ShellyFirmwareIndexStale
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
exp_annotations:
summary: "Shelly firmware index not read successfully for 48 h"
description: "shelly-manager has not completed a refresh of all firmware index sources for 48 hours, so new firmware is not picked up. Check its logs for index errors (TLS pin, egress to *.shelly.cloud, HTTP errors); shelly_manager_firmware_index_errors_total shows which source fails."
# A success that keeps up with time stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_index_last_success_timestamp_seconds{pod="shelly-manager-a"}'
values: '0+60x6000'
alert_rule_test:
- eval_time: 100h
alertname: ShellyFirmwareIndexStale
exp_alerts: []
# Invalid config fires after 10 m; valid config stays silent.
- interval: 1m
input_series:
- series: 'shelly_manager_firmware_config_valid{pod="shelly-manager-a"}'
values: '1 1 0+0x20'
alert_rule_test:
- eval_time: 11m
alertname: ShellyFirmwareConfigInvalid
exp_alerts: []
- eval_time: 12m
alertname: ShellyFirmwareConfigInvalid
exp_alerts:
- exp_labels:
severity: warning
namespace: home-assistant
exp_annotations:
summary: "Shelly firmware rollout disabled by invalid config"
description: "Firmware__Window or Firmware__TimeZone in kubernetes/apps/home-assistant-iot/shelly-manager-deployment.yaml (or the image defaults) is invalid, so shelly-manager starts no automatic firmware updates."
@@ -32,6 +32,7 @@ from music_assistant_models.media_items import (
Podcast,
PodcastEpisode,
ProviderMapping,
RecommendationFolder,
SearchResults,
UniqueList,
)
@@ -62,6 +63,7 @@ UP_NEXT_PLAYLIST_ID = "up_next"
SUPPORTED_FEATURES = {
ProviderFeature.LIBRARY_PODCASTS,
ProviderFeature.LIBRARY_PLAYLISTS,
ProviderFeature.RECOMMENDATIONS,
ProviderFeature.BROWSE,
ProviderFeature.SEARCH,
ProviderFeature.LIBRARY_PODCASTS_EDIT,
@@ -282,6 +284,38 @@ class PocketCastsProvider(MusicProvider):
)
return self._get_up_next_playlist()
async def get_recommendations(self) -> list[RecommendationFolder]:
"""Get the Up Next row, without items."""
return [
RecommendationFolder(
item_id=UP_NEXT_PLAYLIST_ID,
provider=self.instance_id,
name="Up Next",
icon="mdi-playlist-play",
)
]
async def get_recommendation_items(
self, item_id: str
) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
"""
Get the Up Next playlist, shown as the episode at the head of the queue.
:param item_id: The item_id of the row, as returned by get_recommendations.
"""
if item_id != UP_NEXT_PLAYLIST_ID:
return UniqueList()
episodes = await self._get_special_folder_episodes(UP_NEXT_PLAYLIST_ID)
if not episodes:
return UniqueList()
first = episodes[0]
playlist = self._get_up_next_playlist()
playlist.name = first.name
playlist.owner = first.podcast.name
if first.metadata.images:
playlist.metadata.images = first.metadata.images
return UniqueList([playlist])
async def get_playlist_tracks(
self, prov_playlist_id: str, page: int = 0
) -> list[PodcastEpisode]:
+1 -1
View File
@@ -43,7 +43,7 @@ spec:
mountPath: /consume
containers:
- name: vsftpd
image: gitea.renner.uno/konairius/vsftpd:build.1792310564 # {"$imagepolicy": "flux-system:vsftpd"}
image: gitea.renner.uno/konairius/vsftpd:2026-10-28-0649 # {"$imagepolicy": "flux-system:vsftpd"}
ports:
- name: ftp-ctrl
containerPort: 21
+1 -1
View File
@@ -155,7 +155,7 @@ spec:
# exist on both registries — hence konairius/, which matches the ghcr
# namespace. Steady state still pulls from Gitea and never leaves the
# network.
image: gitea.renner.uno/konairius/alpine-openssl:2026-09-04 # {"$imagepolicy": "flux-system:alpine-openssl"}
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-openssl"}
command:
- sh
- -c
+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-27-2231 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-29-1346 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: GITEA_URL
value: https://gitea.renner.uno
+1 -1
View File
@@ -184,7 +184,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path, so it has to exist on both registries.
# Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
+2 -2
View File
@@ -134,7 +134,7 @@ spec:
# is a device-bricking class of risk. Bumps are a reviewed, manual
# act — see kubernetes/infrastructure/image-automation/README.md.
- name: terminus
image: ghcr.io/usetrmnl/terminus:0.78.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
ports:
- name: http
containerPort: 2345
@@ -197,7 +197,7 @@ spec:
capabilities:
drop: ["ALL"]
- name: sidekiq
image: ghcr.io/usetrmnl/terminus:0.78.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
envFrom:
- configMapRef:
+1 -1
View File
@@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: wiki
image: gitea.renner.uno/konairius/wiki:build.1793094262 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:2026-10-28-0700 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
# track the same range/filter so they bump together on each release.
image: woodpeckerci/woodpecker-agent:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
image: woodpeckerci/woodpecker-agent:v3.20.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
env:
- name: WOODPECKER_SERVER
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
+1 -1
View File
@@ -40,7 +40,7 @@ spec:
# minor + patch auto-update within 3.x; only the 4.0 major is held for
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
# -bump hazard is gone). Stays in lockstep with the agent.
image: woodpeckerci/woodpecker-server:v3.19.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
image: woodpeckerci/woodpecker-server:v3.20.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
env:
- name: WOODPECKER_HOST
value: https://ci.renner.uno
@@ -16,6 +16,11 @@ spec:
path: ./kubernetes/apps/authentik
prune: true
wait: true
# Substitutes every ${VAR} under kubernetes/apps/authentik; that path has
# no other ${...} literals. Flux image automation keeps the tag current.
postBuild:
substitute:
SAMBA_BRIDGE_LDAP_OUTPOST_IMAGE: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026-10-28-0655 # {"$imagepolicy": "flux-system:authentik-samba-bridge-ldap-outpost"}
# decryption block dropped 2026-07-14: path is sops-free — secrets come
# from OpenBao via ESO (MIGRATION-sops-openbao.md). This was the LAST
# sops consumer; phase 4 (age-key retirement) may proceed after soak.
@@ -183,6 +183,22 @@ spec:
format: int32
minimum: 0
type: integer
notificationRefs:
description: KumaNotifications attached to the monitor; none attaches
no notification.
items:
description: NotificationRef references a KumaNotification.
properties:
name:
minLength: 1
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
@@ -0,0 +1,166 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
controller-gen.kubebuilder.io/version: v0.23.0
name: kumanotifications.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaNotification
listKind: KumaNotificationList
plural: kumanotifications
singular: kumanotification
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
KumaNotification is the Kuma notification named like the resource. It is
never a default notification and never applied to existing monitors;
KumaMonitors attach it through notificationRefs.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
smtp:
description: |-
SMTPSpec configures an smtp notification. The server and its credentials
come from the Secret: keys host, port, username and password, all required.
Port 465 uses implicit TLS, any other port STARTTLS.
properties:
from:
minLength: 1
type: string
secretRef:
description: SecretRef references a Secret in any namespace.
properties:
name:
minLength: 1
type: string
namespace:
minLength: 1
type: string
required:
- name
- namespace
type: object
to:
minLength: 1
type: string
required:
- from
- secretRef
- to
type: object
type:
enum:
- smtp
type: string
required:
- type
type: object
x-kubernetes-validations:
- message: smtp is required for type smtp and forbidden otherwise
rule: (self.type == 'smtp') == has(self.smtp)
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
notificationID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -2,4 +2,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-kumamonitors.yaml
- crd-kumanotifications.yaml
- crd-kumastatuspages.yaml
@@ -1,4 +1,3 @@
---
# Installs the standard containernetworking/plugins binaries (macvlan,
# static, host-local) into /opt/cni/bin on every node. Talos ships only
# what Cilium installs there (cilium-cni, bandwidth, …); macvlan is
@@ -74,7 +73,7 @@ spec:
# No imagePullSecret: the ghcr namespace is private and its credential
# is in machine.registries too, because a Secret would have to come
# from OpenBao, which needs Ceph, which needs these binaries.
image: gitea.renner.uno/konairius/cni-plugins:2026-10-05 # {"$imagepolicy": "flux-system:cni-plugins"}
image: gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348 # {"$imagepolicy": "flux-system:cni-plugins"}
command:
- sh
- -c
@@ -212,6 +212,26 @@ spec:
- port: "8123"
protocol: TCP
---
# Shelly devices download firmware from shelly-manager through the
# shelly-manager-firmware LoadBalancer. Only :8082, where the app serves
# nothing but /fw/...; the UI on :8080 stays Traefik-only.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: shelly-manager-firmware-world-ingress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: shelly-manager
ingress:
- fromEntities:
- world
toPorts:
- ports:
- port: "8082"
protocol: TCP
---
# Secret rotation: the openbao-ns rotation CronJobs reach HA to roll its three
# long-lived access tokens. HA mints/revokes LLATs only over its WebSocket API,
# so the rotator must reach HA directly.
@@ -360,8 +380,9 @@ spec:
- toEntities:
- world
---
# shelly-manager: HTTP RPC to Shelly devices on the IoT VLAN, and mDNS
# queries for their *.local names (sent out the default interface eth0).
# shelly-manager: HTTP RPC to Shelly devices on the IoT VLAN, mDNS queries
# for their *.local names (sent out the default interface eth0), and Shelly's
# firmware index and image hosts over HTTPS.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -372,8 +393,17 @@ spec:
matchLabels:
app.kubernetes.io/name: shelly-manager
egress:
# v30iot over eth0: IPv4 and the VLAN's ULA prefix.
- toCIDR:
- 10.2.30.0/24
- fd02:30::/64
toPorts:
- ports:
- port: "80"
protocol: TCP
# v30iot global addresses: the delegated prefix is not fixed.
- toCIDR:
- ::/0
toPorts:
- ports:
- port: "80"
@@ -384,6 +414,28 @@ spec:
- ports:
- port: "5353"
protocol: UDP
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
# api.shelly.cloud (Gen1 index), updates.shelly.cloud (Gen2+ index),
# firmware./fwcdn.shelly.cloud and other *.shelly.cloud image hosts.
- toFQDNs:
- matchPattern: "*.shelly.cloud"
toPorts:
- ports:
- port: "443"
protocol: TCP
---
# esphome: OTA/API to devices on the IoT VLAN; HTTPS for GitHub, PlatformIO and PyPI during builds.
apiVersion: cilium.io/v2
@@ -1,5 +1,5 @@
# OpenBao policy: uptime-kuma-vps
# Read on the Uptime Kuma admin and SMTP credentials; read/write on the metrics API key.
# Read on the Uptime Kuma admin credentials; read/write on the metrics API key.
# Bound to auth/oidc role owner, so the owner's login runs the uptime-kuma play. NOT applied by Flux.
#
# bao policy write uptime-kuma-vps kubernetes/infrastructure/openbao/policies/uptime-kuma-vps.hcl
@@ -8,10 +8,6 @@ path "homelab/data/infrastructure/uptime-kuma" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma/smtp" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma-metrics" {
capabilities = ["read", "create", "update"]
}
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$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-27 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$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-27 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-29 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-29 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
+7 -1
View File
@@ -25,6 +25,12 @@ locals {
# Talos K8s control-plane VIP (etcd-elected, see kubernetes/talos/patches/k8s-{1,2,3}.yaml)
"k8s" = { type = "A", value = "10.2.40.60" }
# NTP VIPs held by keepalived (ansible/roles/keepalived-ntp): IPv4
# keepalived_ntp_vip, IPv6 ntp_vip6 (group_vars/ntp_cluster.yml).
# IPv4-only clients (Shelly Gen1) need the A.
"ntp.srv" = { type = "A", value = "10.2.40.4" }
"ntp.srv/AAAA" = { name = "ntp.srv", type = "AAAA", value = "fd02:40::123" }
# AAAA Records
# UniFi auto-creates only the A record for the UDM's own hostname
# (udm -> 192.168.8.1). Pin the AAAA to the UDM's management ULA
@@ -43,7 +49,7 @@ locals {
resource "unifi_dns_record" "renner_uno" {
for_each = local.renner_uno_records
name = each.key == "@" ? "renner.uno" : "${each.key}.renner.uno"
name = each.key == "@" ? "renner.uno" : "${try(each.value.name, each.key)}.renner.uno"
record_type = each.value.type
value = each.value.value
enabled = true