Compare commits

...
Author SHA1 Message Date
hermes-bot e633b1a0c5 fix(lidarr): unpin the ImageRepository and keep Renovate out of image-automation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate (141b11a9, 2026-11-02 03:01Z) pinned a digest into
ImageRepository/lidarr spec.image. image-reflector rejects that
(ImageURLInvalid: "should not include a tag"), so Flux stopped scanning
lidarr and FluxImageAutomationNotReady fires.

Renovate touched it because dab7c10e moved the ImageRepository and the
workload refs from lscr.io to ghcr.io/linuxserver/lidarr, while the
renovate.json list of Flux-managed images kept lscr.io/linuxserver/lidarr.

- lidarr.yaml: spec.image back to ghcr.io/linuxserver/lidarr.
- renovate.json: the list names ghcr.io/linuxserver/lidarr instead of
  lscr.io, so Renovate also leaves the workload refs in
  apps/navidrome/lidarr.yaml to Flux.
- renovate.json: ignorePaths gains kubernetes/infrastructure/image-automation/**.
  That directory holds only ImageRepository/ImagePolicy/ImageUpdateAutomation
  objects; no image ref in it is a deployable pin, and an ImageRepository
  spec.image must never carry a tag or digest. This is the second time
  in a week (gitea-mcp, ffd24865) a list gap let Renovate pin one.

Closes #1551
2026-11-02 13:24:54 +00:00
security-bot da60eba710 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-11-02 11:24:03 +00:00
security-bot ed59b8a32a chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-1051
Updates managed by image-automation-controller.
2026-11-02 11:21:19 +00:00
security-bot 46d91c8fad chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054
Updates managed by image-automation-controller.
2026-11-02 11:12:43 +00:00
security-bot c7e018e7b6 chore(image-automation): bump gitea.renner.uno/konairius/mqtt-account-controller:2026-11-02-1058
Updates managed by image-automation-controller.
2026-11-02 11:05:13 +00:00
security-bot cd5e947baf chore(image-automation): bump gitea.renner.uno/konairius/ical-proxy:2026-11-02-1055
Updates managed by image-automation-controller.
2026-11-02 11:04:56 +00:00
k.renner ce292d629b Merge pull request 'chore(deps): update all non-major dependencies' (#1539) from renovate/all-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1539
2026-11-02 10:53:05 +00:00
k.renner e5d0ffc3e5 Merge pull request 'chore(deps): update hermes image' (#1544) from renovate/hermes-image into main
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was canceled
Reviewed-on: #1544
2026-11-02 10:52:51 +00:00
k.renner dae7550e6f Merge pull request 'chore(deps): update openbao' (#1545) from renovate/openbao into main
Reviewed-on: #1545
2026-11-02 10:52:39 +00:00
k.renner 1177bb839d Merge pull request 'chore(deps): update helm release authentik to v2026.8.5' (#1543) from renovate/authentik into main
Reviewed-on: #1543
2026-11-02 10:52:18 +00:00
k.renner 3c9c001685 Merge pull request 'chore(deps): update home-assistant non-major' (#1541) from renovate/home-assistant-minor-patch into main
Reviewed-on: #1541
2026-11-02 10:51:59 +00:00
k.renner c68befa4a4 Merge pull request 'chore(deps): update ci non-major' (#1540) from renovate/ci-minor-patch into main
ci/woodpecker/push/ci-claude-refresh Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
ci/woodpecker/push/build-ci-images Pipeline was canceled
Reviewed-on: #1540
2026-11-02 10:51:41 +00:00
k.renner 68e4d42f77 Merge pull request 'chore(deps): update influxdb:2.9.1 docker digest to a450f6f' (#1542) from renovate/influxdb into main
Reviewed-on: #1542
2026-11-02 10:51:30 +00:00
k.renner eb035613ab Merge pull request 'chore(deps): pin ghcr.io/linuxserver/lidarr docker tag to 9fddcf5' (#1538) from renovate/pin-image-digests into main
Reviewed-on: #1538
2026-11-02 10:47:13 +00:00
security-bot e8645d11ed chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-1004
Updates managed by image-automation-controller.
2026-11-02 10:21:19 +00:00
k.renner f3c9188807 Merge pull request 'fix(shelly-manager): disable TCP black-hole detection in the pod netns' (#1550) from shelly-manager-no-mtu-probing into main
Reviewed-on: #1550
2026-11-02 09:37:08 +00:00
claude-botandClaude Opus 5.5 c68b9ad7c2 fix(shelly-manager): disable TCP black-hole detection in the pod netns
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Cilium sets net.ipv4.tcp_mtu_probing=1 in every pod netns
(packetization-layer-pmtud-mode: blackhole). Shelly devices stop ACKing
for seconds while they write flash during an OTA; after tcp_retries1
timeouts the kernel treats that as a PMTU black hole and halves the MSS
on every stall, from 1440 down to the 48-byte floor. Upward probes need
an open congestion state with cwnd >= 11, which the stalling device
never allows, so the download stays at 48-byte segments and fills the
device's receive buffer.

The tuning plugin of the IoT attachment sets the sysctl to 0 for the pod
netns; the annotation bump rolls the pod so the attachment is re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-11-02 10:36:01 +01:00
k.renner 59c9cc29fe Merge pull request 'fix(talos): route LoadBalancer replies through table 47' (#1549) from lb-replies-table-47 into main
Reviewed-on: #1549
2026-11-02 07:45:38 +00:00
claude-botandClaude Opus 5.5 3501586841 fix(talos): route LoadBalancer replies through table 47
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
A reply from a Cilium LoadBalancer VIP reaches the routing decision with
the VIP as its source, so the pod-source rules 1000/1001 missed it and
main sent it out mgmt0 while the request came in over ingress0, the BGP
next hop (k8s-3, 2026-11-02: replies from 10.2.99.90 to an IoT device
seen only on mgmt0). Rules 1002/1003 send the pool to table 47.

The pool becomes a site fact: each site patch carries lb-pool-site
(flux-system), and bgp-pool takes its blocks from it by Flux
substitution in infrastructure-crds. The CIDR blocks cover the same
addresses as the start/stop ranges did (allowFirstLastIPs defaults to
Yes). The qemu and hcloud sites carry the production pool, since
Services pin their addresses from it.

Refs #1547

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-11-02 08:42:49 +01:00
security-bot 9e1452cfe7 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-02-0659
Updates managed by image-automation-controller.
2026-11-02 07:21:19 +00:00
renovate-bot fe397f906d chore(deps): update openbao
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:10 +00:00
renovate-bot 48a28d1c8f chore(deps): update hermes image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-11-02 03:02:06 +00:00
renovate-bot a05dcfdc94 chore(deps): update helm release authentik to v2026.8.5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:04 +00:00
renovate-bot 9616ee2ec6 chore(deps): update influxdb:2.9.1 docker digest to a450f6f
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:02:02 +00:00
renovate-bot e0e07c0327 chore(deps): update home-assistant non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:01:59 +00:00
renovate-bot 50307fe748 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-11-02 03:01:57 +00:00
renovate-bot fd0d2ef301 chore(deps): update all non-major dependencies
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-11-02 03:01:52 +00:00
renovate-bot 141b11a9ad chore(deps): pin ghcr.io/linuxserver/lidarr docker tag to 9fddcf5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-02 03:01:48 +00:00
security-bot 03cf6a0e45 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-11-01 11:07:04 +00:00
k.renner 88576f7022 Merge pull request 'revert(authentik-gate-sync): roll back OIDC clients (#1535, #1536), gates are being deleted' (#1537) from revert/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1537
2026-11-01 10:59:29 +00:00
hermes-bot 8c85a49ae5 Revert "Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
This reverts commit 09a03dfd76, reversing
changes made to e518b46045.
2026-11-01 10:44:22 +00:00
hermes-bot 8fa68bc9cd Revert "Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main"
This reverts commit 60dfcf5382, reversing
changes made to 1a99c02996.
2026-11-01 10:44:22 +00:00
security-bot 20d928aeba chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1014
Updates managed by image-automation-controller.
2026-11-01 10:37:04 +00:00
k.renner 60dfcf5382 Merge pull request 'fix(authentik-gate-sync): stop sweeping every gate as an OIDC client' (#1536) from fix/gate-sync-proxy-sweep into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1536
2026-11-01 10:13:55 +00:00
hermes-bot 5e814c525a fix(authentik-gate-sync): stop sweeping every gate as an OIDC client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Outage since 10:11Z: the first pass of image 2026-11-01-0959 (#1535)
removed all 14 gates ("removed OIDC client from Authentik", "14 objects
removed"), and every pass since recreates and removes them again.

ProxyProvider subclasses OAuth2Provider (authentik 2026.8.4
providers/proxy/models.py: class ProxyProvider(OutpostModel,
OAuth2Provider)), so GET providers/oauth2/ also lists every proxy
provider. owned_applications() yielded each gate a second time as an
OAuth2 application; it was not in the OIDC keep set, so the sweep
deleted its application and provider.

Both the sweep and ensure_oidc's provider lookup now take from
providers/oauth2/ only providers whose meta_model_name is
authentik_providers_oauth2.oauth2provider. The test fake now lists proxy
providers under providers/oauth2/ like the real API; the new tests
(gate-only and gate + OIDC client stable over two passes) fail against
the previous controller.
2026-11-01 10:12:58 +00:00
security-bot 1a99c02996 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-0959
Updates managed by image-automation-controller.
2026-11-01 10:07:04 +00:00
k.renner 09a03dfd76 Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1535
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-01 09:59:01 +00:00
hermes-bot f76defb1e1 fix(authentik-gate-sync): review of the OIDC clients
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- Desired redirect URIs carry redirect_uri_type: authorization, which the
  API returns on every entry (RedirectURISerializer default in 2026.8.4);
  without it every pass would re-PATCH the provider.
- OIDC slugs and gate names are kept apart: a slug that is a gate name is
  refused, an existing application without this controller's OAuth2
  provider is refused, and the sweep keeps proxy and OAuth2 applications
  by their own sets, so an OIDCClient can neither take over nor delete a
  gate.
- Status is written only when it changed.
- Adoption of an unassigned oidc-<slug> provider is documented; removal
  logs say OIDC client.
- reconcile() takes k8s/ak for tests; new loop tests: create then quiet,
  duplicate slug, transient error keeps the client, sweep after the
  resource is gone, slug equal to a gate name.
2026-11-01 09:49:33 +00:00
hermes-bot 3e0557cfe8 feat(authentik-gate-sync): OIDC clients as OIDCClient resources, not Ingress annotations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
OIDC is not part of the Ingress. The client is now declared by an
OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds, like kuma-sync-crds) next to the
app: launchURL, redirectURIs, optional slug, group, authorizationFlow.
The controller writes a Ready condition and the issuer to its status.

The auth.renner.uno/oidc-* Ingress annotations are gone; RBAC gains
get/list/watch on oidcclients and patch on their status.
2026-11-01 09:31:43 +00:00
hermes-bot 8739efe026 feat(authentik-gate-sync): OIDC clients from auth.renner.uno/oidc-client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
An Ingress annotated auth.renner.uno/oidc-client: <slug> gets a
confidential OAuth2/OpenID provider (client ID = slug, secret generated by
Authentik), an application with that slug and, with auth.renner.uno/group,
the same group expression policy as gates. auth.renner.uno/oidc-redirect-uri
pins strict redirect URIs on the Ingress host; default is any path on it.

Same fail-closed and ownership rules as gates: owned clients are found via
the OAuth2 provider list and meta_publisher and swept when their Ingress
goes. The service-account role gains the OAuth2 provider permissions plus
view on scope mappings and certificate key pairs.

So apps that log in through Authentik via OIDC (first: YouTrack, #1534)
need no blueprint entries; the groups stay managed in Authentik.
2026-11-01 09:22:25 +00:00
k.renner e518b46045 Merge pull request 'chore(ci): bump ci-claude image to 2.1.316' (#1533) from ci-image/claude-2.1.316 into main
Reviewed-on: #1533
2026-11-01 09:14:15 +00:00
claude-bot d7a6c2bdc7 chore(ci): bump ci-claude image to 2.1.316
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-01 00:01:25 +00:00
k.renner 53d7e716f8 Merge pull request 'fix(loki): enforce retention via the compactor, keep 365 days' (#1532) from worktree-loki-retention-365d into main
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: #1532
2026-10-31 06:10:42 +00:00
security-bot 61e4ee2ba8 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.1
Updates managed by image-automation-controller.
2026-10-31 05:50:56 +00:00
security-bot b447c57e78 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46
Updates managed by image-automation-controller.
2026-10-31 00:23:17 +00:00
security-bot 4f3cf541a4 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.12.0
Updates managed by image-automation-controller.
2026-10-30 19:30:16 +00:00
security-bot 6463c38f68 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343
Updates managed by image-automation-controller.
2026-10-30 13:51:12 +00:00
security-bot b86aaf3f88 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.9
Updates managed by image-automation-controller.
2026-10-30 13:50:57 +00:00
claude-botandClaude Opus 5.5 c9944cfce8 fix(loki): enforce retention via the compactor, keep 365 days
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
limits_config.retention_period was set (2160h) but never enforced: the
live config has no compactor block, so retention_enabled is false and
Loki keeps everything (logs from 2026-05-15 are still queryable).

Enable compactor retention (working_directory under the existing
/var/loki PVC, delete_request_store filesystem) and raise the period to
365 days.

Sizing: storage-loki-0 holds 21.3 GiB of 80 GiB after 5.5 months
(~0.13 GiB/day average, ~0.23 GiB/day over 22.-25.10). 365 days at the
recent rate is ~84 GiB, so the PVC may need expanding later
(ceph-block allows volume expansion).

Rendered with chart loki 7.5.0 and checked with
`loki:3.6.11 -verify-config`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:38:29 +01:00
k.renner 717d1ad619 Merge pull request 'fix(paperless): drop the off-site monitor of the internal Syncthing UI' (#1531) from paperless-drop-syncthing-monitor into main
Reviewed-on: #1531
2026-10-30 13:32:02 +00:00
claude-botandClaude Opus 5.5 40f30412aa fix(paperless): drop the off-site monitor of the internal Syncthing UI
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing.paperless.renner.uno is internal only (no public labels, no public
DNS record), so Uptime Kuma on the VPS cannot reach it and kept the Documents
group down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:29:42 +01:00
security-bot 3f73e7cd3a chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314
Updates managed by image-automation-controller.
2026-10-30 13:23:56 +00:00
k.renner a7543cb78b Merge pull request 'feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF' (#1530) from worktree-alloy-unifi-syslog-label into main
Reviewed-on: #1530
2026-10-30 13:23:48 +00:00
claude-botandClaude Opus 5.5 229e0cf5d8 feat(alloy-syslog): label UniFi plain syslog on udp-5514 separately from CEF
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
UniFi activity logging has a single target, so the UDM SIEM CEF events,
the UDM system log (log_all_contents) and the AP/switch device logs
(debug) all arrive on udp-5514. Split them in loki.process.udm_cef:

- CEF lines keep source="udm-siem" and the existing CEF labels.
- Everything else gets source="unifi-syslog" plus host and app parsed
  from the line header (UDM: "<host> <host> <app>[pid]:", devices:
  "<host> <mac>,<model>-<fw>: [: ]<app>[pid]:").

The listener no longer sets source statically; each stage.match sets it,
so no label is overwritten. Regex checked against 10 min of live lines
(all non-CEF lines match); config validated with alloy v1.21.3
`validate --stability.level=experimental`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:22:28 +01:00
security-bot b7cf7632c6 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1257
Updates managed by image-automation-controller.
2026-10-30 13:21:12 +00:00
k.renner b77b0a4d0d Merge pull request 'feat(kuma-sync): one mail per outage via a root group' (#1525) from kuma-root-group into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1525
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-30 13:14:40 +00:00
security-bot 2485b3ad38 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1158
Updates managed by image-automation-controller.
2026-10-30 12:24:28 +00:00
k.renner a49c852666 Merge pull request 'feat(paperless): Syncthing UI behind the Authentik gate' (#1529) from worktree-syncthing-ui-gate into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1529
2026-10-30 10:45:08 +00:00
claude-botandClaude Opus 5.5 3aa2cf7616 feat(paperless): Syncthing UI behind the Authentik gate
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Serve the UI at syncthing.paperless.renner.uno for Paperless Admins. That hostname was the sync LoadBalancer's DNS name, so the LoadBalancer moves to sync.paperless.renner.uno and the DNS probe follows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:40:45 +01:00
security-bot 0a36c324da chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-0913
Updates managed by image-automation-controller.
2026-10-30 09:24:27 +00:00
security-bot 8d3578f020 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-0728
Updates managed by image-automation-controller.
2026-10-30 07:54:27 +00:00
security-bot 10f1a88802 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-0719
Updates managed by image-automation-controller.
2026-10-30 07:24:27 +00:00
security-bot 4dd46a8e7b chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-0624
Updates managed by image-automation-controller.
2026-10-30 06:54:27 +00:00
security-bot 859d1a7c65 chore(image-automation): bump lscr.io/linuxserver/lidarr:3.1.6.5079-ls46
Updates managed by image-automation-controller.
2026-10-30 06:50:46 +00:00
k.renner 0575a50d9e Merge pull request 'fix(lidarr): scan ghcr.io directly instead of lscr.io redirector' (#1528) from alert-fix/issue-1527 into main
Reviewed-on: #1528
2026-10-30 06:22:12 +00:00
security-bot 33b7b58849 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.0
Updates managed by image-automation-controller.
2026-10-29 22:50:43 +00:00
hermes-bot dab7c10eca fix(lidarr): scan ghcr.io directly instead of lscr.io
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
lscr.io 302-redirects the tag-list request to ghcr.io and the
image-reflector's follow-up arrives unauthenticated (UNAUTHORIZED),
leaving ImageRepository/lidarr not Ready since 2026-10-29T18:56Z.
ghcr.io/linuxserver/lidarr is the same repository (identical digest
for 3.1.6.5079-ls46) and scans anonymously. Workload refs follow so
the imagepolicy setter keeps matching.

Closes #1527
2026-10-29 21:24:11 +00:00
security-bot 378f7e3429 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.20.1
Updates managed by image-automation-controller.
2026-10-29 20:54:22 +00:00
security-bot fdc08827fb chore(image-automation): bump woodpeckerci/woodpecker-server:v3.20.1
Updates managed by image-automation-controller.
2026-10-29 19:54:51 +00:00
security-bot 3d504c2f62 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-1803
Updates managed by image-automation-controller.
2026-10-29 18:24:26 +00:00
k.renner d56deb096e Merge pull request 'feat(shelly-manager): roll firmware out to battery devices' (#1526) from shelly-manager-sleepy-rollout into main
Reviewed-on: #1526
2026-10-29 17:57:37 +00:00
claude-botandClaude Opus 5.5 222965ba47 feat(shelly-manager): roll firmware out to battery devices
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Firmware:SleepyRollout makes shelly-manager push a battery device's update
when it calls in; it was unset, so the rollout never took them along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185Qb5YG28FfnVi1ujMEV5C
2026-10-29 18:54:23 +01:00
security-bot 394bf91c37 chore(image-automation): bump gitea.renner.uno/konairius/mqtt-account-controller:2026-10-29-1634
Updates managed by image-automation-controller.
2026-10-29 16:34:44 +00:00
security-bot 2f7bc33b84 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-1545
Updates managed by image-automation-controller.
2026-10-29 15:54:25 +00:00
security-bot 6c045e2e56 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-29-1524
Updates managed by image-automation-controller.
2026-10-29 15:35:31 +00:00
claude-botandClaude Opus 5.5 811178e321 feat(kuma-sync): one mail per outage via a root group
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Monitors without a parentRef are placed under the root group set by
KUMA_ROOT_GROUP (kuma-sync/renner-uno). Only the root references the
email notification, so a fleet-wide outage sends one DOWN and one UP mail
listing the affected groups. resendInterval (consecutive DOWN heartbeats)
is now a managed KumaMonitor field; the root resends hourly while down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:33:16 +01:00
k.renner 6a39dd1f6e Merge pull request 'chore(ci): bump ci-claude image to 2.1.312' (#1524) from ci-image/claude-2.1.312 into main
Reviewed-on: #1524
2026-10-29 14:50:57 +00:00
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
claude-bot 2e3d055310 chore(ci): bump ci-claude image to 2.1.312
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-29 13:42:08 +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
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
133 changed files with 3081 additions and 567 deletions
+16 -16
View File
@@ -47,7 +47,7 @@ when:
steps:
- name: build-ansible
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/ansible
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
@@ -74,7 +74,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/k8s
when:
- event: deployment
@@ -97,7 +97,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/vmtest
when:
- event: deployment
@@ -120,7 +120,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/tofu
when:
- event: deployment
@@ -150,7 +150,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: secret-rotator
when:
- event: deployment
@@ -175,7 +175,7 @@ steps:
# review-bot: the Go service that reviews pull requests with a Mistral agent (webhook plus sweep).
# Runs after Gitea is up, so no ghcr mirror.
- name: build-review-bot
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: review-bot
when:
- event: deployment
@@ -225,7 +225,7 @@ steps:
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-jq
when:
- event: deployment
@@ -276,7 +276,7 @@ steps:
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cni-plugins
when:
- event: deployment
@@ -316,7 +316,7 @@ steps:
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/alpine-openssl
when:
- event: deployment
@@ -351,7 +351,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/heating-can
when:
- event: deployment
@@ -377,7 +377,7 @@ steps:
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-service-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/netbird-service-sync
when:
- event: deployment
@@ -402,7 +402,7 @@ steps:
# authentik-gate-sync: the authentik-gate ingress class (Ingress -> Authentik
# proxy gate + Traefik route). Its unit tests run as a stage of the image build.
- name: build-authentik-gate-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/authentik-gate-sync
when:
- event: deployment
@@ -428,7 +428,7 @@ steps:
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/kuma-sync
when:
- event: deployment
@@ -454,7 +454,7 @@ steps:
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
@@ -480,7 +480,7 @@ steps:
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/bird
when:
- event: deployment
@@ -504,7 +504,7 @@ steps:
# Orchestrator for the monthly backup-verify CronJob (velero ns).
- name: build-backup-verify
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/backup-verify
when:
- event: deployment
+1 -1
View File
@@ -131,7 +131,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/claude
depends_on: [decide]
environment:
+1 -1
View File
@@ -172,7 +172,7 @@ steps:
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.19.0-alpine@sha256:27a77117e38d7bbdc380f0e90ff3ee4871ab1eefa78b5d9136a2c969fbecd9f4
image: woodpeckerci/woodpecker-cli:v3.20.1-alpine@sha256:74c576faf49cce699825d11c7d7bd38ab5f007dd4f57cdbdd55099d2c0f2621a
commands:
- |
set -eu
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: python:3.15-alpine@sha256:20a82dbc2783112ddb91d1534603f802d222a3e63e593cd002eb2ac534e70211
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -10,7 +10,7 @@ when:
steps:
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7
directory: ci/images/hermes
environment:
# registry-bot write:package PAT (konairius org secret).
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+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
+1 -1
View File
@@ -55,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.
#
+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
+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
@@ -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 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
+2 -2
View File
@@ -23,7 +23,7 @@
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913 AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
@@ -56,7 +56,7 @@ RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& rm -rf /opt/e3oncan/.git
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
+4 -4
View File
@@ -6,10 +6,10 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends jq \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.4@sha256:b3233234327350f20ed194f3fad83d90a64344c1940f839c7b0c0abef50c8c33 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7 /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.9.6@sha256:b1ac18156f227af9a524b842a96f2c20986c7a779ef721df3ba4e4540f449d76 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.1@sha256:6d2b93856e3fcf7b18ad855a0b51eaba474dc8b79cf554379ea32034797d2acf /usr/bin/bao /usr/local/bin/bao
COPY --from=ghcr.io/siderolabs/talosctl:v1.14.5@sha256:0b852e35c1f513ee5eacc22183d6193486012420569fde39fcd5260c51938443 /talosctl /usr/local/bin/talosctl
COPY --from=alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a /usr/local/bin/kubectl /usr/local/bin/kubectl
COPY --from=ghcr.io/fluxcd/flux-cli:v2.10.0@sha256:1a5178b1770052d6e88ab1c7c8434c5ac9bca8a8ab984dcaf6b9df31814ce9f2 /usr/local/bin/flux /usr/local/bin/flux
COPY --from=quay.io/openbao/openbao:2.7.2@sha256:5046911075e137e154ce49ab051502f4ec7cf367d484ae4a4dce665d9b90561f /usr/bin/bao /usr/local/bin/bao
COPY --from=gitea/tea:0.16.0@sha256:af50a2060ce4d8fd76b7ebe75a3d6db4bb0c615c5bc8f2bdfd8ea83207c4752d /usr/bin/tea /usr/local/bin/tea
RUN talosctl version --client \
+2 -2
View File
@@ -1,7 +1,7 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
FROM golang:1.27@sha256:2920bb124f1a2e12a1d39f0cab756ae0068441ee1da7655dec7f5e374944095b AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -9,7 +9,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
@@ -97,6 +97,10 @@ type KumaMonitorSpec struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
// again, not minutes; 0 sends it once per outage.
// +kubebuilder:validation:Minimum=0
ResendInterval int32 `json:"resendInterval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
@@ -108,6 +112,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")
}
@@ -131,6 +131,15 @@ func TestNegativeMaxRetriesRejected(t *testing.T) {
expectInvalid(t, m, "spec.maxRetries")
}
func TestNegativeResendIntervalRejected(t *testing.T) {
m := monitor("neg-resend", KumaMonitorSpec{
Type: "group",
DisplayName: "g",
ResendInterval: -1,
})
expectInvalid(t, m, "spec.resendInterval")
}
func TestJSONQueryBadOperator(t *testing.T) {
m := monitor("jq-bad-op", KumaMonitorSpec{
Type: "json-query",
@@ -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
+32 -4
View File
@@ -3,19 +3,25 @@ package main
import (
"errors"
"fmt"
"strings"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
)
type config struct {
URL string
Username string
Password string
// RootGroup is the zero value when KUMA_ROOT_GROUP is unset.
RootGroup types.NamespacedName
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
// names every missing or invalid variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
var errs []error
for _, v := range []struct {
name string
dst *string
@@ -25,8 +31,30 @@ func loadConfig(getenv func(string) string) (config, error) {
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
errs = append(errs, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
if raw := getenv("KUMA_ROOT_GROUP"); raw != "" {
root, err := parseRootGroup(raw)
if err != nil {
errs = append(errs, fmt.Errorf("KUMA_ROOT_GROUP: %w", err))
}
cfg.RootGroup = root
}
return cfg, errors.Join(errs...)
}
// parseRootGroup parses namespace/name of a KumaMonitor.
func parseRootGroup(raw string) (types.NamespacedName, error) {
ns, name, ok := strings.Cut(raw, "/")
if !ok || strings.Contains(name, "/") {
return types.NamespacedName{}, fmt.Errorf("%q is not namespace/name", raw)
}
if msgs := validation.IsDNS1123Label(ns); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("namespace %q: %s", ns, strings.Join(msgs, "; "))
}
if msgs := validation.IsDNS1123Subdomain(name); len(msgs) > 0 {
return types.NamespacedName{}, fmt.Errorf("name %q: %s", name, strings.Join(msgs, "; "))
}
return types.NamespacedName{Namespace: ns, Name: name}, nil
}
@@ -3,6 +3,8 @@ package main
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/types"
)
func TestLoadConfig(t *testing.T) {
@@ -36,3 +38,21 @@ func TestLoadConfig(t *testing.T) {
}
}
}
func TestLoadConfigRootGroup(t *testing.T) {
env := func(root string) func(string) string {
m := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p", "KUMA_ROOT_GROUP": root}
return func(k string) string { return m[k] }
}
cfg, err := loadConfig(env("kuma-sync/renner-uno"))
if err != nil || cfg.RootGroup != (types.NamespacedName{Namespace: "kuma-sync", Name: "renner-uno"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, bad := range []string{"renner-uno", "/renner-uno", "kuma-sync/", "a/b/c", "Kuma/x", "ns/UPPER", " ns/x"} {
if _, err := loadConfig(env(bad)); err == nil || !strings.Contains(err.Error(), "KUMA_ROOT_GROUP") {
t.Errorf("%q: err = %v, want KUMA_ROOT_GROUP rejected", bad, err)
}
}
}
@@ -48,6 +48,7 @@ func run() error {
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
RootGroup: cfg.RootGroup,
})
if err != nil {
return err
@@ -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:
@@ -206,6 +222,13 @@ spec:
- hostname
- port
type: object
resendInterval:
description: |-
Consecutive DOWN heartbeats after which Kuma sends the DOWN notification
again, not minutes; 0 sends it once per outage.
format: int32
minimum: 0
type: integer
retryInterval:
default: 60
description: Seconds.
@@ -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
+12 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
@@ -41,6 +42,9 @@ type Options struct {
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor that monitors without a parentRef are
// placed under; the zero value leaves them at the top level.
RootGroup types.NamespacedName
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
@@ -72,12 +76,19 @@ func NewManager(o Options) (manager.Manager, error) {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
if err := (&controller.Reconciler{
Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync, RootGroup: o.RootGroup,
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err)
}
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
@@ -54,6 +58,15 @@ type Reconciler struct {
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
// RootGroup is the group KumaMonitor every monitor without a parentRef is
// placed under in Kuma; the zero value places them at the top level.
RootGroup types.NamespacedName
}
func (r *Reconciler) hasRoot() bool { return r.RootGroup.Name != "" }
func (r *Reconciler) isRoot(o client.Object) bool {
return r.hasRoot() && o.GetNamespace() == r.RootGroup.Namespace && o.GetName() == r.RootGroup.Name
}
func (r *Reconciler) resync() time.Duration {
@@ -65,12 +78,19 @@ 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; a change to the root group enqueues the monitors
// without a parentRef.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
b := ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumamonitor").
Complete(r)
Watches(&v1.KumaNotification{}, handler.EnqueueRequestsFromMapFunc(r.referencingMonitors),
builder.WithPredicates(predicate.Funcs{UpdateFunc: notificationIDChanged}))
if r.hasRoot() {
b = b.Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.rootChildren),
builder.WithPredicates(r.rootPredicate()))
}
return b.Named("kumamonitor").Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
@@ -82,18 +102,22 @@ 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"
msgRootPending = "root group has no monitor ID yet"
msgRootNotGroup = "root group must be of type group"
msgRootHasParent = "root group must not have a parentRef"
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) {
@@ -111,29 +135,43 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
var p parentOutcome
var err error
switch {
case km.Spec.ParentRef != nil:
p, err = r.resolveParent(ctx, km)
case r.hasRoot() && !r.isRoot(km):
p, err = r.resolveRoot(ctx)
}
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
if p.id != 0 {
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 +288,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
@@ -257,8 +346,75 @@ type parentOutcome struct {
requeue time.Duration
}
// rootChanged reports changes to the root group that alter what the monitors
// without a parentRef resolve to.
func rootChanged(e event.UpdateEvent) bool {
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
if !ok1 || !ok2 {
return true
}
return o.Status.MonitorID != n.Status.MonitorID ||
o.Spec.Type != n.Spec.Type ||
(o.Spec.ParentRef == nil) != (n.Spec.ParentRef == nil) ||
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
}
// rootPredicate passes events of the root group only.
func (r *Reconciler) rootPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return r.isRoot(e.Object) },
DeleteFunc: func(e event.DeleteEvent) bool { return r.isRoot(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return r.isRoot(e.Object) },
UpdateFunc: func(e event.UpdateEvent) bool { return r.isRoot(e.ObjectNew) && rootChanged(e) },
}
}
// rootChildren enqueues the KumaMonitors of all namespaces that have no
// parentRef, except the root group itself.
func (r *Reconciler) rootChildren(ctx context.Context, _ 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 i := range list.Items {
km := &list.Items[i]
if km.Spec.ParentRef == nil && !r.isRoot(km) {
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: km.Namespace, Name: km.Name}})
}
}
return reqs
}
// resolveRoot returns the Kuma ID of the root group for a monitor without a
// parentRef. The root must be a group without a parentRef: under one of its
// own ancestors it would close a cycle in Kuma.
func (r *Reconciler) resolveRoot(ctx context.Context) (parentOutcome, error) {
pending := parentOutcome{reason: ReasonParentPending, message: msgRootPending, requeue: parentRetry}
root := &v1.KumaMonitor{}
err := r.Client.Get(ctx, r.RootGroup, root)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
switch {
case root.Spec.Type != "group":
return parentOutcome{reason: ReasonInvalidParent, message: msgRootNotGroup, requeue: r.resync()}, nil
case root.Spec.ParentRef != nil:
return parentOutcome{reason: ReasonInvalidParent, message: msgRootHasParent, requeue: r.resync()}, nil
case root.Status.MonitorID == 0 || !root.DeletionTimestamp.IsZero():
return pending, nil
}
return parentOutcome{id: root.Status.MonitorID}, nil
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
// and be a group, without cycles or more than maxParentDepth levels. The root
// group, which the topmost ancestor is placed under, counts as a level.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
@@ -268,7 +424,8 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
depth := 0
for ; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
@@ -293,6 +450,9 @@ func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (par
}
cur = parent
}
if r.hasRoot() && !r.isRoot(cur) && depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
if directID == 0 {
return pending, nil
}
@@ -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)
@@ -24,14 +24,15 @@ func OwnerKey(km *v1.KumaMonitor) string {
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
ResendInterval: s.ResendInterval,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
@@ -57,6 +57,8 @@ func TestMappingAllTypes(t *testing.T) {
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
resend := base("group")
resend.ResendInterval = 60
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
@@ -74,6 +76,8 @@ func TestMappingAllTypes(t *testing.T) {
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
wantResend := common("group")
wantResend.ResendInterval = 60
cases := []struct {
name string
@@ -88,6 +92,7 @@ func TestMappingAllTypes(t *testing.T) {
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
{"resend interval", resend, wantResend},
}
for _, c := range cases {
got := Desired(mk(c.spec))
@@ -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)
}
}
}
@@ -0,0 +1,209 @@
package controller
import (
"fmt"
"slices"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
)
const rootID = 7
var rootName = types.NamespacedName{Namespace: "default", Name: "root"}
func rootGroup() *v1.KumaMonitor {
g := group("root", "")
g.Status.MonitorID = rootID
return g
}
func newRootEnv(t *testing.T, objs ...client.Object) *env {
t.Helper()
e := newEnv(t, objs...)
e.r.RootGroup = rootName
return e
}
func (e *env) parentOf(t *testing.T, name string) *int64 {
t.Helper()
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil {
t.Fatalf("%s: no Kuma monitor", name)
}
return got.Parent
}
func TestRootGroupParentsParentless(t *testing.T) {
e := newRootEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p == nil || *p != rootID {
t.Errorf("parent = %v, want %d", p, rootID)
}
}
func TestRootGroupKeepsParentRef(t *testing.T) {
grp := group("grp", "")
grp.Status.MonitorID = 42
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newRootEnv(t, rootGroup(), grp, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "child"); p == nil || *p != 42 {
t.Errorf("parent = %v, want 42", p)
}
}
func TestRootGroupItselfHasNoParent(t *testing.T) {
root := rootGroup()
root.Status.MonitorID = 0
e := newRootEnv(t, root)
if _, err := e.reconcile(t, "root"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "root"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupUnsetLeavesTopLevel(t *testing.T) {
e := newEnv(t, rootGroup(), httpMonitor("a"))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if p := e.parentOf(t, "a"); p != nil {
t.Errorf("parent = %d, want none", *p)
}
}
func TestRootGroupPending(t *testing.T) {
noID := rootGroup()
noID.Status.MonitorID = 0
deletingRoot := deleting(rootGroup())
cases := map[string][]client.Object{
"missing": nil,
"no ID": {noID},
"deleting": {deletingRoot},
"not group": {func() *v1.KumaMonitor { m := httpMonitor("root"); m.Status.MonitorID = rootID; return m }()},
"has parent": {func() *v1.KumaMonitor {
m := rootGroup()
m.Spec.ParentRef = &v1.LocalRef{Name: "x"}
return m
}()},
}
want := map[string]struct {
reason string
requeue time.Duration
}{
"missing": {ReasonParentPending, 10 * time.Second},
"no ID": {ReasonParentPending, 10 * time.Second},
"deleting": {ReasonParentPending, 10 * time.Second},
"not group": {ReasonInvalidParent, 5 * time.Minute},
"has parent": {ReasonInvalidParent, 5 * time.Minute},
}
for name, objs := range cases {
e := newRootEnv(t, append(objs, httpMonitor("a"))...)
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != want[name].requeue {
t.Errorf("%s: RequeueAfter = %v, want %v", name, res.RequeueAfter, want[name].requeue)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != want[name].reason {
t.Errorf("%s: Ready = %+v", name, c)
}
if e.cnt.creates != 0 {
t.Errorf("%s: creates = %d", name, e.cnt.creates)
}
}
}
// A chain of maxParentDepth groups is allowed on its own, but the root group
// adds one more level above it.
func TestRootGroupCountsTowardsDepth(t *testing.T) {
var objs []client.Object
for i := range maxParentDepth {
parent := ""
if i < maxParentDepth-1 {
parent = fmt.Sprintf("c%d", i+1)
}
objs = append(objs, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
objs = append(objs, deep)
if _, err := newEnv(t, objs...).reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
e := newRootEnv(t, append(objs, rootGroup())...)
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Reason != ReasonInvalidParent || c.Message != msgParentTooDeep {
t.Errorf("Ready = %+v", c)
}
// A chain ending in the root itself gets no extra level.
objs[maxParentDepth-1] = func() *v1.KumaMonitor { g := rootGroup(); g.Name = "c9"; return g }()
e = newEnv(t, objs...)
e.r.RootGroup = types.NamespacedName{Namespace: "default", Name: "c9"}
if _, err := e.reconcile(t, "deep"); err != nil {
t.Fatal(err)
}
if c := ready(t, e.get(t, "deep")); c.Status != metav1.ConditionTrue {
t.Errorf("chain ending in root: Ready = %+v", c)
}
}
func TestRootChildrenEnqueuesParentless(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
other := httpMonitor("other")
other.Namespace = "elsewhere"
e := newRootEnv(t, rootGroup(), group("grp", ""), child, httpMonitor("a"), other)
var got []string
for _, req := range e.r.rootChildren(t.Context(), rootGroup()) {
got = append(got, req.String())
}
slices.Sort(got)
want := []string{"default/a", "default/grp", "elsewhere/other"}
if !slices.Equal(got, want) {
t.Errorf("enqueued %v, want %v", got, want)
}
}
func TestRootPredicate(t *testing.T) {
r := &Reconciler{RootGroup: rootName}
p := r.rootPredicate()
old := rootGroup()
old.Status.MonitorID = 0
withID := rootGroup()
other := group("other", "")
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: withID}) {
t.Error("root ID change must pass")
}
if p.Update(event.UpdateEvent{ObjectOld: withID, ObjectNew: withID.DeepCopy()}) {
t.Error("unchanged root must not pass")
}
if p.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other.DeepCopy()}) || p.Create(event.CreateEvent{Object: other}) {
t.Error("other monitors must not pass")
}
if !p.Create(event.CreateEvent{Object: withID}) || !p.Delete(event.DeleteEvent{Object: withID}) {
t.Error("root create and delete must pass")
}
}
@@ -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
}
@@ -374,7 +378,7 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (proxy, resend interval, upside down, auth)
// fields we do not manage (proxy, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
@@ -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{
@@ -439,6 +420,7 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
ResendInterval: int32(base.ResendInterval),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
@@ -600,6 +582,7 @@ func writeBase(b *monitor.Base, m Monitor) {
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.ResendInterval = int64(m.ResendInterval)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
@@ -127,7 +127,7 @@ func TestCreateFieldsIntact(t *testing.T) {
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
Interval: 60, RetryInterval: 60, MaxRetries: 0, ResendInterval: 60, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
@@ -313,7 +313,6 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
@@ -328,8 +327,8 @@ func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
if !base.UpsideDown {
t.Errorf("%s: unmanaged state lost: upsideDown=%v", when, base.UpsideDown)
}
}
@@ -344,7 +343,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Interval: 60, RetryInterval: 60, ResendInterval: 7, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
@@ -360,6 +359,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
cleared.ResendInterval = 0
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
@@ -369,7 +369,7 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.ResendInterval != 0 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, "after plain update")
@@ -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
@@ -139,6 +141,9 @@ type Monitor struct {
Interval int32
RetryInterval int32
MaxRetries int32
// ResendInterval counts consecutive DOWN heartbeats between repeated DOWN
// notifications; 0 disables resending.
ResendInterval int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
@@ -196,9 +201,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)
}
}
@@ -0,0 +1,61 @@
package kumaclient
import (
"encoding/json"
"testing"
"github.com/breml/go-uptime-kuma-client/monitor"
)
// resendOnWire writes m over existing (nil for a create) and returns the wire
// resendInterval and the value read back from it.
func resendOnWire(t *testing.T, m Monitor, existing *monitor.Base) (wire, back int64) {
t.Helper()
w, err := toWire(m, existing)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
var f struct {
ResendInterval int64 `json:"resendInterval"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
var base monitor.Base
if err := json.Unmarshal(raw, &base); err != nil {
t.Fatal(err)
}
got, err := toDomain(base, m.OwnerKey)
if err != nil {
t.Fatal(err)
}
return f.ResendInterval, int64(got.ResendInterval)
}
func TestResendIntervalRoundTrip(t *testing.T) {
for _, m := range []Monitor{
{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 60, OwnerKey: "ns/g"},
{ID: 2, Type: "http", Name: "h", Active: true, Interval: 60, RetryInterval: 60, ResendInterval: 3, URL: "https://e.com", OwnerKey: "ns/h"},
} {
wire, back := resendOnWire(t, m, nil)
if wire != int64(m.ResendInterval) || back != int64(m.ResendInterval) {
t.Errorf("%s: wire=%d read back=%d, want %d", m.Type, wire, back, m.ResendInterval)
}
}
}
// 0 must overwrite a resend interval stored in Kuma, not keep it.
func TestResendIntervalClearedOnUpdate(t *testing.T) {
var existing monitor.Base
if err := json.Unmarshal([]byte(`{"id":1,"type":"group","name":"g","resendInterval":7}`), &existing); err != nil {
t.Fatal(err)
}
m := Monitor{ID: 1, Type: "group", Name: "g", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "ns/g"}
if wire, back := resendOnWire(t, m, &existing); wire != 0 || back != 0 {
t.Errorf("wire=%d read back=%d, want 0", wire, back)
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
FROM python:3.15-slim@sha256:94d3426f1def7db4a106a232ac121bfd975393e5dfd40a619b955fae45fe7913
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
+7
View File
@@ -22,6 +22,13 @@ Two rules, one per family, send every packet with a pod source to table 47
(`talos/patches/site-homelab.yaml`, priorities 1000/1001, below main). The
table decides by longest prefix; no rule carves out exceptions.
A reply from a LoadBalancer VIP is pod traffic as well, but it reaches the
routing decision with the VIP as its source. Two more rules (1002/1003) send
the LoadBalancer pool to table 47, so the reply leaves over `ingress0`, where
the request arrived as the BGP next hop. Without them main sent it out
`mgmt0`. The pool is a site fact: the site patch's `lb-pool-site` ConfigMap,
which `bgp-pool` takes by Flux substitution, and the rules beside it.
bird (`kubernetes/infrastructure/bird/`) owns table 47 and fills it with:
- the node's attached networks — `mgmt0`, `ingress0`, `pod0`, `node0` —
@@ -48,7 +48,7 @@ spec:
# cert when doing server-side OIDC token exchange. Bundle both
# roots into one PEM since NODE_EXTRA_CA_CERTS accepts one file.
- name: fetch-le-staging-roots
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
@@ -103,6 +103,16 @@ data:
slug: paperless-proxy
state: absent
# syncthing.paperless.renner.uno is gated by authentik-gate-sync
# (kubernetes/apps/paperless/syncthing.yaml), which admits this group.
# Members are managed in Authentik.
- model: authentik_core.group
identifiers:
name: Paperless Admins
state: present
attrs:
is_superuser: false
# vscode.home.renner.uno is gated by authentik-gate-sync
# (kubernetes/apps/home-assistant/vscode-ingress.yaml), which admits this
# group. Members are managed in Authentik.
+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-28-0712 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -20,7 +20,7 @@ spec:
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
# floating range drifts the chart ahead of the pinned app image.
version: "2026.8.4"
version: "2026.8.5"
sourceRef:
kind: HelmRepository
name: authentik
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-28-0718 # {"$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
@@ -28,7 +28,7 @@ spec:
enableServiceLinks: false
containers:
- name: evcc
image: evcc/evcc:0.318.1@sha256:2f8163ab22f04863afd86cd511c09607ba71debd9eebd65511f3d654922a80d1
image: evcc/evcc:0.318.2@sha256:7ef9bcb15a4fdbfe3bf75d7a0857c40b6272b8783ea833fcbb22f8f134b336c7
# evcc auto-discovers /etc/evcc.yaml on its default search path;
# the file is delivered (with the MQTT password) via the
# sops-encrypted evcc-config Secret mounted below.
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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:2026-10-28-0704 # {"$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,11 @@ 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.
- Parentless monitors hang under the root group `kuma-sync/renner-uno` (controller env
`KUMA_ROOT_GROUP`). Only the root carries `notificationRefs`; don't add `notificationRefs`
to service monitors.
- The CRD lives in `infrastructure/kuma-sync-crds`. Every consuming Flux Kustomization needs
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
@@ -88,7 +88,7 @@ spec:
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -130,7 +130,7 @@ spec:
# depends on the kernel's bindv6only default rather than on anything
# stated here, so the roles are made explicit instead of inherited.
- name: socat-ipv6
image: alpine/socat:1.8.1.3@sha256:603174b77eebaba25ba8f8ebb15e1234d6600409554e95d6fb203fd106150287
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
imagePullPolicy: IfNotPresent
command:
- /bin/sh
@@ -46,7 +46,7 @@ spec:
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"
net.renner.uno/iot-attachment-config: "tuning-no-ra-no-mtu-probing"
spec:
# eth0 (Cilium-managed pod network) is still the primary — it
# carries the ingress, mosquitto LB return path, etc. The macvlan
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-10-29-0807 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1051 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -106,6 +106,9 @@ spec:
# The URL devices download firmware from: Service shelly-manager-firmware.
- name: Firmware__PublicBaseUrl
value: http://firmware.shelly-manager.renner.uno
# Battery devices get their update when they call in.
- name: Firmware__SleepyRollout
value: "true"
- name: ShellyManager__DnsSuffix
value: iot.renner.uno
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
@@ -50,6 +50,9 @@ spec:
# 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.
#
# tcp_mtu_probing (pod-wide; Cilium sets 1): Shelly flash-write stalls
# trip black-hole detection, which cuts firmware downloads to 48-byte MSS.
config: |
{
"cniVersion": "0.3.1",
@@ -67,7 +70,8 @@ spec:
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.accept_ra": "0",
"net.ipv6.conf.IFNAME.autoconf": "0"
"net.ipv6.conf.IFNAME.autoconf": "0",
"net.ipv4.tcp_mtu_probing": "0"
}
},
{
@@ -60,7 +60,7 @@ spec:
# repo's configuration.yaml reads this key, so HA does not start
# without it. Removing this init container takes the recorder down.
- name: render-db-url
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command:
- sh
- -c
@@ -97,7 +97,7 @@ spec:
# (homeassistant repo packages/matrix_assist.yaml), same filter-then-append
# render as recorder_db_url above. Value is hex, nothing to escape.
- name: render-matrix-password
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command:
- sh
- -c
@@ -275,7 +275,7 @@ spec:
# any LAN client can edit, matching the user's trust-the-network
# model. Runs as root so its writes match HA's root-owned files.
- name: code-server
image: codercom/code-server:4.143.1@sha256:377b66bec5645510ed52549bb00e648c273edc604d230a3f90fd2b2433afcb97
image: codercom/code-server:4.144.0@sha256:b9e8056bb74bd0c4f90284c7559eddeccd38400cfedc28e64c9d3ca50fef1f83
args:
- "--auth=none"
- "--bind-addr=0.0.0.0:8080"
@@ -337,7 +337,7 @@ spec:
# dedicated long-lived token.
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
- name: config-sync
image: alpine/git:2.54.0@sha256:e8ceedc73bd80992ae8d58a7d24c58b8322146c354f1d2e81a97db4452068ec0
image: alpine/git:2.54.0@sha256:ac6c828f15d82775d204d3d75112e1d34af8945da80ccf922713c9891ba68e7a
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
@@ -36,7 +36,7 @@ spec:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.10.0@sha256:1dff19a59070cdb523b76576cdc74d200f862d43c1407809088855920cf57145
image: ghcr.io/esphome/esphome:2026.10.1@sha256:eab8cd6b4f51077c3883af4cedc058280ddc34a94f3b2ffee23e9d2cd5f1db64
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
@@ -223,7 +223,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-10-28-0657 # {"$imagepolicy": "flux-system:mqtt-account-controller"}
image: gitea.renner.uno/konairius/mqtt-account-controller:2026-11-02-1058 # {"$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-10-28-0711 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
@@ -163,7 +163,7 @@ spec:
# Pinned tag. No Flux image-automation marker yet (unlike mosquitto) —
# add an infrastructure/image-automation/zigbee2mqtt.yaml policy as a
# follow-up if desired; pinned-and-manual is fine for the initial cutover.
image: ghcr.io/koenkk/zigbee2mqtt:2.14.2@sha256:addbfb08fcef2e4477de1535636118241e10d13e898fbe4d9faba8a4d7396311
image: ghcr.io/koenkk/zigbee2mqtt:2.14.3@sha256:fb4a6b0f6868ad1cd6a9996479802b2420e599c3463b62bbff8c8cd9a908f43a
imagePullPolicy: IfNotPresent
env:
- name: TZ
+1 -1
View File
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: ical-proxy
image: gitea.renner.uno/konairius/ical-proxy:2026-10-28-0651 # {"$imagepolicy": "flux-system:ical-proxy"}
image: gitea.renner.uno/konairius/ical-proxy:2026-11-02-1055 # {"$imagepolicy": "flux-system:ical-proxy"}
ports:
- containerPort: 8080
name: http
+1 -1
View File
@@ -66,7 +66,7 @@ spec:
# The library PVC is populated by the seed phase before this
# Deployment reconciles, so no rsync init is needed here.
- name: fetch-le-staging-roots
image: curlimages/curl:8.23.0@sha256:11e64e28c5f58c8d93ee95fcf22ac055f48b0bc440e0ce7d8013bbb5ad6aa8ac
image: curlimages/curl:8.23.0@sha256:3fb7e13e5d179a845c97d78204f3f1064d1907037f26a3ebf91710571151fd93
command:
- sh
- -c
@@ -57,7 +57,7 @@ spec:
runAsNonRoot: false
initContainers:
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -80,7 +80,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+2 -2
View File
@@ -88,7 +88,7 @@ spec:
# state). Two-stage so we don't try to create an auth before
# the chart's `influx setup` has finished.
- name: wait-for-influxd
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
@@ -111,7 +111,7 @@ spec:
echo "[$(date -u +%H:%M:%S)] $INFLUX_HOST ready and authenticated"
containers:
- name: create
image: influxdb:2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2
image: influxdb:2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417
env:
- { name: INFLUX_HOST, value: http://influxdb-influxdb2.influxdb.svc.cluster.local:8086 }
- { name: INFLUX_ORG, value: renner }
+1 -1
View File
@@ -55,7 +55,7 @@ spec:
repository: influxdb
# Was pinned to VM 108's 2.7.4 for the one-time `influx restore`; that
# migration is done. No downgrade below 2.9 without `influxd downgrade`.
tag: "2.9.1@sha256:8f106f7ff12659c5e367b99f8a0e7d14cbd4e6ca1f2914094062db6a9813d9c2"
tag: "2.9.1@sha256:a450f6faf9573e6c2e96a0f575885ae8b7387cc87e4de94599e247ebfa3ac417"
# Single replica — InfluxDB OSS doesn't cluster. RWO Ceph-block + Velero
# backups (pending) is the durability story.
persistence:
+3 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-28-0712 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -44,6 +44,8 @@ spec:
secretKeyRef:
name: kuma-sync-kuma
key: password
- name: KUMA_ROOT_GROUP
value: kuma-sync/renner-uno
ports:
- name: metrics
containerPort: 8080
@@ -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
@@ -0,0 +1,14 @@
# Root group (KUMA_ROOT_GROUP): parent of every monitor without a parentRef; the only one with notificationRefs.
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: kuma-sync
spec:
type: group
displayName: renner.uno
interval: 60
# heartbeats, not minutes: 60 x 60s = hourly reminder while down
resendInterval: 60
notificationRefs:
- name: email
@@ -5,3 +5,5 @@ resources:
- rbac.yaml
- deployment.yaml
- statuspage-renner.yaml
- notification-email.yaml
- kumamonitor-root.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
+2 -2
View File
@@ -48,7 +48,7 @@ spec:
# this sentinel: the entrypoint copies AccountsSettings.json
# into ephemeral /config-internal rather than symlinking it.
- name: seed-config
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
image: busybox:1.38.0@sha256:a46257e06c82ad85242d0d38077d0a52d9896389ca60a1a59d3463db586e5fa4
command: ["/bin/sh", "-eu", "-c"]
args:
- |
@@ -94,7 +94,7 @@ spec:
- name: libation
# Pinned by digest: Spegel caches mutable tag resolutions, so
# :latest stayed on 13.7.11 despite imagePullPolicy Always.
image: docker.io/rmcrackan/libation:14.2.2@sha256:fe188ac3c4ce63cc305971c76e648748341427e16bcf884011da8a384fd3908c
image: docker.io/rmcrackan/libation:14.2.5@sha256:7bb47a417311d154c6691747320d0b62ce15527280ad99f0e2a85ac9a6b07e40
# The entrypoint stages config into ephemeral /config-internal
# by copy (only the DB is symlinked to the PVC), so Audible
# tokens refreshed during a run are lost at container exit.
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
type: RuntimeDefault
containers:
- name: element-web
image: ghcr.io/element-hq/element-web:v1.12.32@sha256:f8700633d4336258af7a05b53865a4f479e0d628cf2d9ca83d28b5939314d4ce
image: ghcr.io/element-hq/element-web:v1.12.33@sha256:b0aa4b5a4fc1afaaff62e2dbd0c92d57e2b20724f3ccb2fef53aae949088c3d2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+1 -1
View File
@@ -137,7 +137,7 @@ spec:
type: RuntimeDefault
containers:
- name: mas
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1@sha256:02712e68c90f3743fedbd5e3343517481b2b4f246a9fd9e2f050dc2553033847
image: ghcr.io/element-hq/matrix-authentication-service:1.28.0@sha256:494c67f08ee8344368f4ed336611dea02b26c4e058bf37a212d090d50b612659
# The server applies database migrations and syncs the upstream
# providers from the config on start.
args: ["server", "--config", "/config/config.yaml"]
+2 -2
View File
@@ -179,7 +179,7 @@ spec:
# first. The marker on the data PVC makes later starts skip it.
# Debug image: the plain one has no shell for the marker check.
- name: migrate-to-mas
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1-debug@sha256:75f369b206785f1480f4a9f8dd81d7f9f3c6793c40fd184358ecd5d7acae17d4
image: ghcr.io/element-hq/matrix-authentication-service:1.28.0-debug@sha256:362b8cc6b1176aec059f3539da2d47c8b47587d1584431bf1aca285146d42b08
command:
- /busybox/sh
- -ec
@@ -234,7 +234,7 @@ spec:
mountPath: /data
containers:
- name: synapse
image: ghcr.io/element-hq/synapse:v1.163.2@sha256:c3c21919a322f74d177a43bce8031adc81ef839ddd117a8dd03cfec350e76e02
image: ghcr.io/element-hq/synapse:v1.164.1@sha256:fa4aa980f279bcf26d4e7ecc2a2c9a701c73f2ebe8841da9bdcf3b2fdf693d75
# Bypass /start.py: it expects to run as root and template a config
# from env. Default mode is generate-missing-and-run, which creates
# the signing key on first start.
@@ -142,4 +142,4 @@ spec:
- "[fd02:47::1]:53"
- "10.2.47.1:53"
labels:
dns_name: syncthing.paperless.renner.uno
dns_name: sync.paperless.renner.uno
@@ -158,43 +158,69 @@ spec:
relabel_rules = loki.relabel.syslog.rules
}
// UniFi UDM SIEM Server (Network app) — UDP/5514. UDM emits CEF
// (Common Event Format), not RFC syslog: payload starts with
// "<bsd-ts> <iso-ts> <device-name> CEF:0|Vendor|Product|...".
// raw listener captures verbatim; loki.process.udm_cef extracts
// CEF header + UNIFI* extension fields and promotes a curated
// low-cardinality set to labels.
// UniFi activity logging — UDP/5514, one target for two payloads:
// the UDM SIEM events in CEF ("<bsd-ts> <device-name> CEF:0|...")
// and plain syslog from the UDM (log_all_contents) and from every
// adopted AP/switch (debug). raw listener captures verbatim;
// loki.process.udm_cef sets `source` per payload type.
loki.source.syslog "udp_5514" {
listener {
address = "0.0.0.0:5514"
protocol = "udp"
syslog_format = "raw"
labels = { job = "syslog", listener = "udp-5514", source = "udm-siem" }
labels = { job = "syslog", listener = "udp-5514" }
}
forward_to = [loki.process.udm_cef.receiver]
}
// CEF parser for UDM SIEM payload. The listener also receives the
// UDM's plain syslog (log_all_contents), which does not match and
// passes through unlabelled. CEF extension keys differ per event
// type, so the regex anchors only on the header
// (vendor|product|product_version|sig_id|name|sev), UNIFIcategory,
// UNIFIhost and the trailing msg. The full raw line stays the log
// content.
loki.process "udm_cef" {
forward_to = [loki.write.default.receiver]
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
// CEF: extension keys differ per event type, so the regex
// anchors only on the header
// (vendor|product|product_version|sig_id|name|sev),
// UNIFIcategory, UNIFIhost and the trailing msg. The full raw
// line stays the log content.
stage.match {
selector = "{listener=\"udp-5514\"} |= \"CEF:\""
stage.static_labels {
values = { source = "udm-siem" }
}
stage.regex {
expression = "CEF:\\d+\\|(?P<vendor>[^|]*)\\|(?P<product>[^|]*)\\|(?P<product_version>[^|]*)\\|(?P<signature_id>[^|]*)\\|(?P<event_name>[^|]*)\\|(?P<cef_severity>[^|]*)\\|UNIFIcategory=(?P<category>.+?) UNIFIhost=(?P<unifi_host>.+?) \\S+=.* msg=(?P<msg>.+)$"
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
}
}
}
stage.labels {
values = {
vendor = "vendor",
product = "product",
signature_id = "signature_id",
event_name = "event_name",
category = "category",
// Plain syslog. UDM: "<ts> <host> <host> <app>[pid]: ...";
// AP/switch: "<ts> <host> <mac>,<model>-<fw>: [: ]<app>[pid]: ...".
stage.match {
selector = "{listener=\"udp-5514\"} != \"CEF:\""
stage.static_labels {
values = { source = "unifi-syslog" }
}
stage.regex {
expression = "^\\S+ +\\d+ \\S+ (?P<host>\\S+) (?:[0-9a-f]{12},\\S+:(?: :)? |\\S+ )(?P<app>[A-Za-z0-9_./+-]+)"
}
stage.labels {
values = {
host = "host",
app = "app",
}
}
}
}
@@ -136,7 +136,7 @@ spec:
prober: dns
timeout: 5s
dns:
query_name: syncthing.paperless.renner.uno
query_name: sync.paperless.renner.uno
query_type: A
valid_rcodes: [NOERROR]
validate_answer_rrs:
@@ -39,8 +39,14 @@ spec:
replication_factor: 1
storage:
type: filesystem
# retention_period is only enforced by the compactor with
# retention_enabled; Loki keeps logs forever otherwise.
compactor:
working_directory: /var/loki/compactor
retention_enabled: true
delete_request_store: filesystem
limits_config:
retention_period: 2160h
retention_period: 8760h
ingestion_rate_mb: 10
ingestion_burst_size_mb: 20
max_streams_per_user: 10000
@@ -17,7 +17,7 @@ spec:
chart:
spec:
chart: kube-prometheus-stack
version: "92.13.1"
version: "92.17.0"
sourceRef:
kind: HelmRepository
name: prometheus-community
@@ -54,7 +54,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -67,7 +67,7 @@ spec:
# the stock file is the one it was written against (2.10.7 = 2.10.8);
# any other version keeps the stock provider.
- name: pocketcasts-overlay
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
command:
- /bin/sh
- -euc
@@ -88,7 +88,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
+1 -1
View File
@@ -74,7 +74,7 @@ spec:
# alpine/kubectl is the maintained replacement. Pin the tag
# to the cluster's server version so kubectl is in lock-step
# (mismatched skew can break exec streaming).
image: docker.io/alpine/kubectl:1.37.1@sha256:a5d0fbecab3f7b6c2ab7398f1daa73a3e0ed6ece18556d21698233e9e3759cc7
image: docker.io/alpine/kubectl:1.37.2@sha256:5e2de75d37e811ba6eb92e06890b1b3263eae4f0a1c17b49269edec589b1352a
env:
- name: DATASET_TLV
valueFrom:
+1 -1
View File
@@ -47,7 +47,7 @@ spec:
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:f3e43ccd86d6da87323873efa0c9b6f92a2a8cc75a804ea50e34c96680363e2e
image: openthread/otbr:latest@sha256:51f9af250252f4faae85d0197574a7c17ada873c78799854461a93e3e42cf324
command:
- bash
- -c

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